Reubencf commited on
Commit
c1b5f7a
·
1 Parent(s): 0b225d2

committing since i am heading to bed

Browse files
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
- id: `slide-${index + 1}`,
74
- title: slide.title || `Slide ${index + 1}`,
75
- subtitle: slide.subtitle || undefined,
76
- content: Array.isArray(slide.content) ? slide.content : (slide.content ? [slide.content] : []),
77
- notes: slide.notes || '',
78
- layout: normalizeLayout(slide.layout || '', index, total),
79
- imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
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 response = await hf.generateSlideContent(systemPrompt, model);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- id: `slide-${index + 1}`,
145
- title: slide.title || `Slide ${index + 1}`,
146
- subtitle: slide.subtitle || undefined,
147
- content: Array.isArray(slide.content) ? slide.content : (slide.content ? [slide.content] : []),
148
- notes: slide.notes || '',
149
- layout: normalizeLayout(slide.layout || '', index, total),
150
- imageKeyword: slide.imageKeyword || slide.imageKeywords || '',
151
- }));
 
 
 
 
 
 
152
 
153
  console.log(`Parsed ${slides.length} slides`);
154
  } catch (error) {
155
- console.error('HuggingFace generation error:', error);
 
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: ['Understanding the fundamentals', 'Historical context and relevance', 'Why this topic matters'], layout: 'title_and_text', imageKeyword: '' },
184
- { id: 'slide-4', title: 'Key Concepts', content: ['Primary concepts and definitions', 'Core principles', 'Essential components'], layout: 'image_and_text', imageKeyword: `${prompt.toLowerCase()} concept` },
185
- { id: 'slide-5', title: 'Analysis', content: ['Current trends', 'Key insights', 'Critical analysis'], layout: 'title_and_text', imageKeyword: '' },
186
- { id: 'slide-6', title: 'Benefits & Impact', content: ['Expected benefits', 'Measurable impact', 'Long-term value'], layout: 'title_and_text', imageKeyword: '' },
187
- { id: 'slide-7', title: 'References', content: ['Industry publications and reports'], layout: 'references', imageKeyword: '' },
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
- export async function GET(request: NextRequest) {
33
- try {
34
- const searchParams = request.nextUrl.searchParams;
35
- const query = searchParams.get('query');
36
- const perPage = searchParams.get('per_page') || '1';
37
- const page = searchParams.get('page') || '1';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- id: 'placeholder',
52
- urls: {
53
- regular: `https://images.unsplash.com/photo-1557683316-973673baf926?w=800&q=80`,
54
- small: `https://images.unsplash.com/photo-1557683316-973673baf926?w=400&q=80`,
55
- thumb: `https://images.unsplash.com/photo-1557683316-973673baf926?w=200&q=80`,
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
- if (response.status === 401) {
83
- return NextResponse.json(
84
- { error: 'Invalid Unsplash API key. Please check your configuration.' },
85
- { status: 401 }
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
- error: 'Failed to search images',
112
- details: error instanceof Error ? error.message : 'Unknown error'
113
- },
114
- { status: 500 }
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: 4px 4px 0px 0px rgba(0,0,0,1); }
338
- .neo-shadow-lg { box-shadow: 6px 6px 0px 0px rgba(0,0,0,1); }
339
- .neo-shadow-purple { box-shadow: 4px 4px 0px 0px #A000A0; }
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
- 'use client';
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
- {/* Select Tool */}
132
- <ToolButton title="Select" active={!selectedId}>
133
- <MousePointer2 className="w-5 h-5" />
134
- </ToolButton>
135
-
136
- <Divider />
137
 
138
- {/* Text Tool */}
139
- <ToolButton title="Text" onClick={addText}>
140
- <Type className="w-5 h-5" />
141
- </ToolButton>
142
-
143
- {/* Duplicate/Copy */}
144
- <ToolButton title="Duplicate">
145
- <Copy className="w-5 h-5" />
146
- </ToolButton>
147
-
148
- {/* Link */}
149
- <ToolButton title="Link">
150
- <Link className="w-5 h-5" />
151
- </ToolButton>
152
-
153
- {/* Image with dropdown */}
154
- <div className="relative">
155
- <ToolButton title="Image" onClick={() => setShowImageMenu(!showImageMenu)} dropdown>
156
- <Image className="w-5 h-5" />
157
- </ToolButton>
158
- {showImageMenu && (
159
- <>
160
- <div className="fixed inset-0 z-40" onClick={() => setShowImageMenu(false)} />
161
- <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]">
162
- <button
163
- onClick={() => { addImage(); setShowImageMenu(false); }}
164
- 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"
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
- <Divider />
 
 
183
 
184
  {/* Layout Selector */}
185
- {availableLayouts && availableLayouts.length > 0 && onLayoutChange && (
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
- {availableLayouts.map((layout) => (
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 && availableLayouts && availableLayouts.length > 0 && (
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
- {availableLayouts.map((layout) => (
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 && spec.body) {
74
- const newBody = [...spec.body];
75
- newBody[index] = { ...newBody[index], text: value };
76
  return { ...spec, body: newBody };
77
  }
78
- if (field === 'items' && index !== undefined && spec.items) {
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 headingFont = themes[currentTheme].headingFont || 'Arial';
181
- const bodyFont = themes[currentTheme].bodyFont || 'Arial';
 
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: themes[currentTheme].titleColor,
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: themes[currentTheme].textColor,
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: themes[currentTheme].textColor,
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: themes[currentTheme].textColor,
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: themes[currentTheme].titleColor,
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: themes[currentTheme].textColor,
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: currentTheme,
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) ? undefined : (Array.isArray(slide.content) ? slide.content.map((text: string) => ({ text })) : slide.content ? [{ text: slide.content }] : undefined),
347
- items: layout === 'agenda' ? (Array.isArray(slide.content) ? slide.content.map((text: string) => ({ text })) : undefined) : undefined,
 
 
 
 
 
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', currentTheme),
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
- id: createId(),
387
- elements: createLayoutElements('titleContent', currentTheme),
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: (themes[currentTheme] as any).solidBackground || (themes[currentTheme] as any).background
 
 
1395
  }}
1396
  >
1397
  {isTemplateTheme && slideSpecs[idx] ? (
1398
  /* Template thumbnail via SlideFactory */
1399
- <div style={{ transform: 'scale(0.125)', transformOrigin: '0 0', width: 800, height: 450 }}>
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={() => setShowUnsplashSearch(true)}
 
 
 
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) => { addImageFromUrl(url); setShowUnsplashSearch(false); }}
1588
- onClose={() => setShowUnsplashSearch(false)}
 
 
 
 
 
 
 
 
 
 
 
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 scale-[0.1] origin-top-left transform">
36
- <div key={`${slide.id}-${theme}`} className="w-[1000px] h-[562px]">
37
- {renderSlide(slide, theme)}
38
- </div>
39
- </div>
40
- </div>
 
 
 
 
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
- {templates.map((t) => (
230
  <div
231
- key={t}
232
  onClick={() => {
233
- setTemplate(t);
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 === t && <Check className="h-3.5 w-3.5" />}
240
  </span>
241
- {t}
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 'image_and_text': return <NeoImageAndText {...commonProps} />;
 
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, { useState } from '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
- // GALERYN THEME CONSTANTS
24
- // ============================================================================
 
 
 
 
 
 
 
 
 
25
 
26
  const COLORS = {
27
- bg: '#fbf9f4',
28
  text: '#021d30',
29
- accent: '#021d30',
30
  secondary: '#d4e8d4',
31
- cardBg: '#f5f3ee',
32
- placeholder: '#eae8e3',
33
  };
34
 
35
- const FONTS = {
36
- serif: 'Newsreader, "Times New Roman", Georgia, serif',
37
- sans: 'Manrope, "Helvetica Neue", Arial, sans-serif',
38
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- // ============================================================================
41
- // 1. GalerynTitleSubtitle
42
- // ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  export function GalerynTitleSubtitle({
45
  title,
46
  subtitle,
 
47
  styles,
48
  slideId,
49
  isEditable = false,
50
  onFieldUpdate,
 
51
  }: LayoutProps) {
52
- const [editingField, setEditingField] = useState<string | null>(null);
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
- className="w-full h-full flex flex-col items-center justify-center relative overflow-hidden p-8"
75
- style={{ backgroundColor: COLORS.bg }}
 
76
  >
77
- {/* Title */}
78
- {editingField === 'title' ? (
79
- <input
80
- type="text"
81
- value={tempTitle}
82
- onChange={(e) => setTempTitle(e.target.value)}
83
- onBlur={() => handleBlur('title')}
84
- onKeyDown={(e) => handleKeyDown(e, 'title')}
85
- className="w-full text-center bg-transparent outline-none"
86
- style={{
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
- {subtitle || (isEditable ? 'Click to add subtitle' : '')}
148
- </p>
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 [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
169
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-row relative overflow-hidden"
192
- style={{ backgroundColor: COLORS.bg }}
 
193
  >
194
- {/* Left strip — narrow image / decorative area */}
195
- <div
196
- className="flex flex-col justify-end p-5"
197
- style={{ width: '18%', backgroundColor: COLORS.placeholder }}
198
- >
199
- <span
200
- style={{
201
- fontSize: '10px',
202
- fontFamily: FONTS.serif,
203
- fontStyle: 'italic',
204
- color: COLORS.text,
205
- opacity: 0.5,
206
- lineHeight: 1.5,
207
- }}
208
- >
209
- {subtitle || 'A curated overview of topics and themes.'}
210
- </span>
211
  </div>
212
 
213
- {/* Right main content */}
214
- <div className="flex-1 flex flex-col p-8">
215
- {/* Title */}
216
- {editingField?.field === 'title' ? (
217
- <input
218
- type="text"
219
- value={tempTitle}
220
- onChange={(e) => setTempTitle(e.target.value)}
221
- onBlur={() => handleBlur('title')}
222
- onKeyDown={(e) => handleKeyDown(e, 'title')}
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
- {title}
245
- </h2>
246
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
- {/* Items list */}
249
- <div className="flex flex-col flex-1">
250
- {items.map((item, i) => (
251
- <div
252
- key={i}
253
- className="flex items-center justify-between py-3"
254
- style={{ borderBottom: `1px solid rgba(2, 29, 48, 0.1)` }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  >
256
- {/* Item text */}
257
- {editingField?.field === 'items' && editingField?.index === i ? (
258
- <input
259
- type="text"
260
- value={tempItems[i] || ''}
261
- onChange={(e) => {
262
- const next = [...tempItems];
263
- next[i] = e.target.value;
264
- setTempItems(next);
265
- }}
266
- onBlur={() => handleBlur('items', i)}
267
- onKeyDown={(e) => handleKeyDown(e, 'items', i)}
268
- className="flex-1 bg-transparent outline-none"
269
- style={{
270
- fontSize: '14px',
271
- fontFamily: FONTS.sans,
272
- color: COLORS.text,
273
- }}
274
- autoFocus
275
- />
276
- ) : (
277
  <span
278
- className={`flex-1 ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
279
  style={{
280
- fontSize: '14px',
281
- fontFamily: FONTS.sans,
282
- color: COLORS.text,
283
- lineHeight: 1.5,
284
- }}
285
- onClick={() => {
286
- if (isEditable) {
287
- setEditingField({ field: 'items', index: i });
288
- setTempItems(items.map((x) => x.text));
289
- }
290
  }}
291
  >
292
- {item.text}
293
  </span>
294
- )}
295
 
296
- {/* Zero-padded number */}
297
- <span
298
- style={{
299
- fontSize: '12px',
300
- fontFamily: FONTS.sans,
301
- color: COLORS.text,
302
- opacity: 0.35,
303
- marginLeft: '16px',
304
- fontVariantNumeric: 'tabular-nums',
305
- }}
306
- >
307
- {String(i + 1).padStart(2, '0')}
308
- </span>
309
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
330
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-row items-center relative overflow-hidden p-10"
353
- style={{ backgroundColor: COLORS.bg }}
 
354
  >
355
- {/* Left column Title */}
356
- <div className="w-1/2 pr-8 flex flex-col justify-center">
357
- {editingField?.field === 'title' ? (
358
- <input
359
- type="text"
360
- value={tempTitle}
361
- onChange={(e) => setTempTitle(e.target.value)}
362
- onBlur={() => handleBlur('title')}
363
- onKeyDown={(e) => handleKeyDown(e, 'title')}
364
- className="w-full bg-transparent outline-none"
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
- {title}
389
- </h2>
390
- )}
391
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
- {/* Right column Body */}
394
- <div className="w-1/2 pl-8 flex flex-col justify-center gap-4">
395
- {body.map((b, i) => (
396
- <div key={i}>
397
- {b.heading && (
398
- <span
399
- style={{
400
- fontSize: '10px',
401
- fontFamily: FONTS.sans,
402
- fontWeight: 700,
403
- color: COLORS.text,
404
- textTransform: 'uppercase' as const,
405
- letterSpacing: '0.1em',
406
- opacity: 0.45,
407
- display: 'block',
408
- marginBottom: '4px',
409
- }}
410
- >
411
- {b.heading}
412
- </span>
413
- )}
414
-
415
- {editingField?.field === 'body' && editingField?.index === i ? (
416
- <textarea
417
- value={tempBody[i] || ''}
418
- onChange={(e) => {
419
- const next = [...tempBody];
420
- next[i] = e.target.value;
421
- setTempBody(next);
422
- }}
423
- onBlur={() => handleBlur('body', i)}
424
- onKeyDown={(e) => handleKeyDown(e, 'body', i)}
425
- className="w-full bg-transparent outline-none resize-none"
426
- style={{
427
- fontSize: '14px',
428
- fontFamily: FONTS.sans,
429
- color: COLORS.text,
430
- lineHeight: 1.7,
431
- opacity: 0.8,
432
- }}
433
- rows={3}
434
- autoFocus
435
- />
436
- ) : (
437
- <p
438
- className={`${isEditable ? 'cursor-pointer hover:opacity-60' : ''}`}
439
- style={{
440
- fontSize: '14px',
441
- fontFamily: FONTS.sans,
442
- color: COLORS.text,
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 [editingField, setEditingField] = useState<string | null>(null);
477
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-row relative overflow-hidden"
499
- style={{ backgroundColor: COLORS.bg }}
 
500
  >
501
- {/* Left Image */}
502
- <div className="w-1/2 h-full relative" style={{ backgroundColor: COLORS.placeholder }}>
503
- {imageUrl ? (
504
- <img
505
- src={imageUrl}
506
- alt={title}
507
- className="w-full h-full object-cover"
508
- />
509
- ) : (
510
- <div className="w-full h-full flex items-center justify-center">
511
- <svg
512
- className="w-10 h-10"
513
- fill="none"
514
- stroke={COLORS.text}
515
- strokeWidth={0.8}
516
- viewBox="0 0 24 24"
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
- <span
 
534
  style={{
535
- fontSize: '8px',
536
- fontFamily: FONTS.sans,
537
- fontWeight: 600,
538
- color: COLORS.text,
539
- textTransform: 'uppercase' as const,
540
- letterSpacing: '0.1em',
541
- opacity: 0.7,
542
  }}
543
  >
544
- 001 Stories
545
- </span>
546
- </div>
 
 
 
 
 
 
 
 
 
 
547
  </div>
548
 
549
- {/* Right — Text */}
550
  <div
551
- className="w-1/2 h-full flex flex-col justify-center p-10"
552
- style={{ backgroundColor: COLORS.cardBg }}
553
  >
554
- {/* Title */}
555
- {editingField === 'title' ? (
556
- <input
557
- type="text"
558
- value={tempTitle}
559
- onChange={(e) => setTempTitle(e.target.value)}
560
- onBlur={() => handleBlur('title')}
561
- onKeyDown={(e) => handleKeyDown(e, 'title')}
562
- className="w-full bg-transparent outline-none mb-4"
563
- style={{
564
- fontSize: '28px',
565
- fontFamily: FONTS.serif,
566
- fontWeight: 700,
567
- color: COLORS.text,
568
- lineHeight: 1.2,
569
- }}
570
- autoFocus
571
- />
572
- ) : (
573
- <h2
574
- className={`mb-4 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
575
- style={{
576
- fontSize: '28px',
577
- fontFamily: FONTS.serif,
578
- fontWeight: 700,
579
- color: COLORS.text,
580
- lineHeight: 1.2,
581
- }}
582
- onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
583
- >
584
- {title}
585
- </h2>
586
- )}
587
 
588
- {/* Body */}
589
- {editingField === 'body' ? (
590
- <textarea
591
- value={tempBody}
592
- onChange={(e) => setTempBody(e.target.value)}
593
- onBlur={() => handleBlur('body')}
594
- onKeyDown={(e) => handleKeyDown(e, 'body')}
595
- className="w-full bg-transparent outline-none resize-none"
596
- style={{
597
- fontSize: '14px',
598
- fontFamily: FONTS.sans,
599
- color: COLORS.text,
600
- lineHeight: 1.7,
601
- opacity: 0.8,
602
- }}
603
- rows={5}
604
- autoFocus
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 [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
639
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-col relative overflow-hidden p-10"
662
- style={{ backgroundColor: COLORS.bg }}
 
663
  >
664
- {/* Title */}
665
- {editingField?.field === 'title' ? (
666
- <input
667
- type="text"
668
- value={tempTitle}
669
- onChange={(e) => setTempTitle(e.target.value)}
670
- onBlur={() => handleBlur('title')}
671
- onKeyDown={(e) => handleKeyDown(e, 'title')}
672
- className="bg-transparent outline-none mb-6"
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
- {title}
694
- </h2>
695
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
696
 
697
- {/* Reference Items */}
698
- <div className="flex flex-col flex-1">
699
- {items.map((item, i) => (
700
- <div
701
- key={i}
702
- className="flex items-start gap-4 py-3"
703
- style={{ borderBottom: '1px solid rgba(2, 29, 48, 0.1)' }}
704
- >
705
- {/* Numbered label */}
706
- <span
707
- className="shrink-0"
708
- style={{
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
- [{i + 1}]
719
- </span>
 
 
 
 
 
 
 
 
 
 
 
720
 
721
- {/* Item text */}
722
- {editingField?.field === 'items' && editingField?.index === i ? (
723
- <input
724
- type="text"
725
- value={tempItems[i] || ''}
726
- onChange={(e) => {
727
- const next = [...tempItems];
728
- next[i] = e.target.value;
729
- setTempItems(next);
730
- }}
731
- onBlur={() => handleBlur('items', i)}
732
- onKeyDown={(e) => handleKeyDown(e, 'items', i)}
733
- className="flex-1 bg-transparent outline-none"
734
- style={{
735
- fontSize: '13px',
736
- fontFamily: FONTS.sans,
737
- color: COLORS.text,
738
- }}
739
- autoFocus
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 [editingField, setEditingField] = useState<string | null>(null);
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
- className="w-full h-full flex flex-col items-center justify-center relative overflow-hidden p-10"
803
- style={{ backgroundColor: COLORS.text }}
 
804
  >
805
- {/* Title */}
806
- {editingField === 'title' ? (
807
- <input
808
- type="text"
809
- value={tempTitle}
810
- onChange={(e) => setTempTitle(e.target.value)}
811
- onBlur={() => handleBlur('title')}
812
- onKeyDown={(e) => handleKeyDown(e, 'title')}
813
- className="w-full text-center bg-transparent outline-none"
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
- {title}
836
- </h1>
837
- )}
838
-
839
- {/* Subtitle */}
840
- {(subtitle || isEditable) && (
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
- fontSize: '14px',
862
- fontFamily: FONTS.sans,
863
- color: COLORS.bg,
864
- opacity: 0.6,
865
- lineHeight: 1.6,
866
  }}
867
- onClick={() => { if (isEditable) { setEditingField('subtitle'); setTempSubtitle(subtitle || ''); } }}
 
 
 
 
 
 
 
 
 
 
 
868
  >
869
- {subtitle || (isEditable ? 'Click to add subtitle' : '')}
870
- </p>
871
- )
872
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
873
 
874
- {/* Bottom label */}
875
  <div
876
- className="absolute bottom-6 left-0 right-0 text-center"
877
  style={{
878
- fontSize: '8px',
879
- fontFamily: FONTS.sans,
880
  fontWeight: 600,
881
- color: COLORS.bg,
882
- opacity: 0.3,
883
  letterSpacing: '0.3em',
884
- textTransform: 'uppercase' as const,
 
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 opacity-30 pointer-events-none"
30
  style={{ background: pattern }}
31
  />
32
  );
33
  }
34
 
35
- // ============================================================================
36
- // 1. NeoTitleSubtitle
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') { e.preventDefault(); handleBlur(field); }
 
 
 
60
  if (e.key === 'Escape') {
61
  setEditingField(null);
62
- if (field === 'title') setTempTitle(title);
63
- if (field === 'subtitle') setTempSubtitle(subtitle || '');
64
  }
65
  };
66
 
67
  return (
68
  <div
69
- className="w-full h-full flex flex-col items-center justify-center relative overflow-hidden"
 
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 text-center px-6">
75
- {/* Title box */}
76
- <div
77
- className="relative"
 
 
 
 
 
78
  >
79
- {/* Star decoration */}
80
- <Star
81
- className="absolute -top-3 -right-3 text-neo-purple fill-neo-yellow"
82
- size={22}
83
- strokeWidth={2.5}
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
- {/* Subtitle card */}
129
- {(subtitle || isEditable) && (
130
- <div
131
- className="mt-4"
132
- >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  {editingField === 'subtitle' ? (
134
- <input
135
- type="text"
136
  value={tempSubtitle}
137
  onChange={(e) => setTempSubtitle(e.target.value)}
138
  onBlur={() => handleBlur('subtitle')}
139
  onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
140
- className="neo-border text-center outline-none"
 
141
  style={{
142
  fontFamily: styles.fonts.body,
143
- fontSize: '14px',
144
- fontWeight: 700,
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={`neo-border ${isEditable ? 'cursor-pointer' : ''}`}
156
  style={{
157
  fontFamily: styles.fonts.body,
158
- fontSize: '14px',
159
- fontWeight: 700,
160
  color: '#000',
 
 
 
161
  textTransform: 'uppercase',
162
- padding: '8px 20px',
163
- backgroundColor: '#fff',
164
- display: 'inline-block',
165
- letterSpacing: '0.04em',
 
 
 
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((i) => i.text));
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') { e.preventDefault(); handleBlur(field, index); }
 
 
 
205
  if (e.key === 'Escape') {
206
  setEditingField(null);
207
- if (field === 'title') setTempTitle(title);
208
- if (field === 'items') setTempItems(items.map((i) => i.text));
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
- className="w-full h-full flex flex-col relative overflow-hidden p-7"
 
218
  style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
219
  >
220
  <DotOverlay pattern={styles.dotPattern} />
221
 
222
- <div className="relative z-10 flex flex-col h-full">
223
- {/* Title */}
224
- <div
225
- className="mb-5"
 
 
 
 
 
226
  >
227
  {editingField?.field === 'title' ? (
228
- <input
229
- type="text"
230
  value={tempTitle}
231
  onChange={(e) => setTempTitle(e.target.value)}
232
  onBlur={() => handleBlur('title')}
233
  onKeyDown={(e) => handleKeyDown(e, 'title')}
234
- className="bg-transparent outline-none"
 
235
  style={{
236
  fontFamily: styles.fonts.heading,
237
- fontSize: '32px',
238
  fontWeight: 900,
239
  color: '#000',
 
 
240
  textTransform: 'uppercase',
241
- letterSpacing: '-0.02em',
242
  }}
 
243
  autoFocus
244
  />
245
  ) : (
246
  <h2
247
- className={`${isEditable ? 'cursor-pointer' : ''}`}
248
  style={{
249
  fontFamily: styles.fonts.heading,
250
- fontSize: '32px',
251
  fontWeight: 900,
252
  color: '#000',
 
 
253
  textTransform: 'uppercase',
254
- letterSpacing: '-0.02em',
 
 
 
 
 
 
255
  }}
256
- onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
257
  >
258
- {title}
259
  </h2>
260
  )}
261
- </div>
262
 
263
- {/* Cards grid */}
264
- <div className="grid grid-cols-3 gap-3 flex-1">
265
- {displayItems.map((item, i) => (
266
- <div
267
- key={i}
268
- className="neo-border neo-shadow-purple bg-white flex flex-col justify-between"
269
- style={{
270
- padding: '14px 16px',
271
- minHeight: '120px',
272
- }}
273
  >
274
- <span
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={`mt-2 ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
309
  style={{
310
- fontFamily: styles.fonts.body,
311
- fontSize: '14px',
312
- fontWeight: 700,
313
- color: '#000',
314
- }}
315
- onClick={() => {
316
- if (isEditable) {
317
- setEditingField({ field: 'items', index: i });
318
- setTempItems(items.map((x) => x.text));
319
- }
320
  }}
321
  >
322
- {item.text}
323
  </span>
324
- )}
325
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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((b) => b.text));
 
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) { e.preventDefault(); handleBlur(field, index); }
 
 
 
359
  if (e.key === 'Escape') {
360
  setEditingField(null);
361
- if (field === 'title') setTempTitle(title);
362
- if (field === 'body') setTempBody(body.map((b) => b.text));
363
  }
364
  };
365
 
366
- const bodyText = body.length > 0 ? body[0].text : '';
367
-
368
  return (
369
  <div
370
- className="w-full h-full flex flex-col items-center justify-center relative overflow-hidden p-8"
 
371
  style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
372
  >
373
  <DotOverlay pattern={styles.dotPattern} />
374
 
375
- <div className="relative z-10 flex flex-col items-center text-center max-w-[680px] w-full">
376
- {/* Title in cyan box */}
377
- <div
 
 
 
 
 
 
378
  >
379
- {editingField?.field === 'title' ? (
380
- <input
381
- type="text"
382
- value={tempTitle}
383
- onChange={(e) => setTempTitle(e.target.value)}
384
- onBlur={() => handleBlur('title')}
385
- onKeyDown={(e) => handleKeyDown(e, 'title')}
386
- className="bg-neo-cyan neo-border neo-shadow text-center outline-none"
387
- style={{
388
- fontFamily: styles.fonts.heading,
389
- fontSize: '32px',
390
- fontWeight: 900,
391
- color: '#000',
392
- textTransform: 'uppercase',
393
- padding: '8px 24px',
394
- width: '100%',
395
- }}
396
- autoFocus
397
- />
398
- ) : (
399
- <h2
400
- className={`bg-neo-cyan neo-border neo-shadow ${isEditable ? 'cursor-pointer' : ''}`}
401
- style={{
402
- fontFamily: styles.fonts.heading,
403
- fontSize: '32px',
404
- fontWeight: 900,
405
- color: '#000',
406
- textTransform: 'uppercase',
407
- padding: '8px 24px',
408
- display: 'inline-block',
409
- }}
410
- onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
411
- >
412
- {title}
413
- </h2>
414
- )}
415
- </div>
 
 
 
 
 
 
 
 
 
416
 
417
- {/* Body text card */}
418
- <div
419
- className="mt-5 w-full"
 
 
 
 
 
420
  >
421
- {editingField?.field === 'body' && editingField?.index === 0 ? (
422
- <textarea
423
- value={tempBody[0] || ''}
424
- onChange={(e) => {
425
- const next = [...tempBody];
426
- next[0] = e.target.value;
427
- setTempBody(next);
428
- }}
429
- onBlur={() => handleBlur('body', 0)}
430
- onKeyDown={(e) => handleKeyDown(e, 'body', 0)}
431
- className="neo-border bg-white w-full outline-none resize-none text-center"
432
- style={{
433
- fontFamily: styles.fonts.body,
434
- fontSize: '16px',
435
- color: '#000',
436
- lineHeight: 1.65,
437
- padding: '16px 20px',
438
- }}
439
- rows={4}
440
- autoFocus
441
- />
442
- ) : (
443
- <p
444
- className={`neo-border bg-white ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
445
- style={{
446
- fontFamily: styles.fonts.body,
447
- fontSize: '16px',
448
- color: '#000',
449
- lineHeight: 1.65,
450
- padding: '16px 20px',
451
- }}
452
- onClick={() => {
453
- if (isEditable) {
454
- setEditingField({ field: 'body', index: 0 });
455
- setTempBody(body.map((b) => b.text));
456
- }
457
- }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  >
459
- {bodyText || (isEditable ? 'Click to add text' : '')}
460
- </p>
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.length > 0 ? body[0].text : '');
 
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 !== (body[0]?.text || '')) onFieldUpdate(slideId, 'body', tempBody, 0);
489
  setEditingField(null);
490
  };
491
 
492
  const handleKeyDown = (e: React.KeyboardEvent, field: string) => {
493
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleBlur(field); }
 
 
 
494
  if (e.key === 'Escape') {
495
  setEditingField(null);
496
- if (field === 'title') setTempTitle(title);
497
- if (field === 'body') setTempBody(body.length > 0 ? body[0].text : '');
498
  }
499
  };
500
 
501
- const bodyText = body.length > 0 ? body[0].text : '';
502
-
503
  return (
504
  <div
505
- className="w-full h-full flex flex-col relative overflow-hidden p-7"
 
506
  style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
507
  >
508
  <DotOverlay pattern={styles.dotPattern} />
509
 
510
- <div className="relative z-10 flex flex-col h-full">
511
- {/* Title in lime box */}
512
- <div
513
- className="mb-4 self-start"
 
 
 
 
 
514
  >
515
- {editingField === 'title' ? (
516
- <input
517
- type="text"
518
- value={tempTitle}
519
- onChange={(e) => setTempTitle(e.target.value)}
520
- onBlur={() => handleBlur('title')}
521
- onKeyDown={(e) => handleKeyDown(e, 'title')}
522
- className="bg-neo-lime neo-border neo-shadow outline-none"
523
- style={{
524
- fontFamily: styles.fonts.heading,
525
- fontSize: '26px',
526
- fontWeight: 900,
527
- color: '#000',
528
- textTransform: 'uppercase',
529
- padding: '6px 18px',
 
 
 
530
  }}
531
- autoFocus
532
- />
533
- ) : (
534
- <h2
535
- className={`bg-neo-lime neo-border neo-shadow ${isEditable ? 'cursor-pointer' : ''}`}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
536
  style={{
537
  fontFamily: styles.fonts.heading,
538
- fontSize: '26px',
539
  fontWeight: 900,
540
  color: '#000',
541
  textTransform: 'uppercase',
542
- padding: '6px 18px',
543
- display: 'inline-block',
544
  }}
545
- onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
546
  >
547
- {title}
548
- </h2>
549
- )}
550
- </div>
551
 
552
- {/* Two-column layout */}
553
- <div className="flex gap-5 flex-1 min-h-0">
554
- {/* Image side */}
555
- <div
556
- className="w-[48%] shrink-0 neo-border overflow-hidden"
557
- style={{ backgroundColor: '#fff' }}
558
- >
559
- {imageUrl ? (
560
- <img
561
- src={imageUrl}
562
- alt={title}
563
- className="w-full h-full object-cover"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
  />
565
  ) : (
566
- <div
567
- className="w-full h-full flex items-center justify-center"
568
  style={{
569
- border: '3px dashed #000',
570
- margin: '8px',
571
- width: 'calc(100% - 16px)',
572
- height: 'calc(100% - 16px)',
 
 
 
 
 
 
 
 
 
 
573
  }}
574
  >
575
- <span
576
- style={{
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
- {/* Text side */}
592
- <div
593
- className="flex-1 flex flex-col justify-center"
594
- >
595
- <div
596
- className="neo-border bg-white h-full flex items-center"
597
- style={{ padding: '16px 18px' }}
598
- >
599
- {editingField === 'body' ? (
600
- <textarea
601
- value={tempBody}
602
- onChange={(e) => setTempBody(e.target.value)}
603
- onBlur={() => handleBlur('body')}
604
- onKeyDown={(e) => handleKeyDown(e, 'body')}
605
- className="w-full h-full bg-transparent outline-none resize-none"
606
- style={{
607
- fontFamily: styles.fonts.body,
608
- fontSize: '14px',
609
- color: '#000',
610
- lineHeight: 1.65,
611
- }}
612
- autoFocus
613
- />
614
- ) : (
615
- <p
616
- className={`${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
617
- style={{
618
- fontFamily: styles.fonts.body,
619
- fontSize: '14px',
620
- color: '#000',
621
- lineHeight: 1.65,
622
- }}
623
- onClick={() => { if (isEditable) { setEditingField('body'); setTempBody(bodyText); } }}
624
- >
625
- {bodyText || (isEditable ? 'Click to add description' : '')}
626
- </p>
627
- )}
628
- </div>
629
  </div>
630
- </div>
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((i) => i.text));
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') { e.preventDefault(); handleBlur(field, index); }
 
 
 
662
  if (e.key === 'Escape') {
663
  setEditingField(null);
664
- if (field === 'title') setTempTitle(title);
665
- if (field === 'items') setTempItems(items.map((i) => i.text));
666
  }
667
  };
668
 
669
  return (
670
  <div
671
- className="w-full h-full flex flex-col relative overflow-hidden p-7"
 
672
  style={{ backgroundColor: '#F5F5F0', color: styles.colors.text }}
673
  >
674
  <DotOverlay pattern={styles.dotPattern} />
675
 
676
- <div className="relative z-10 flex flex-col h-full">
677
- {/* Title in black box with white text */}
678
- <div
679
- className="mb-5 self-start"
 
 
 
 
 
680
  >
681
- {editingField?.field === 'title' ? (
682
- <input
683
- type="text"
684
- value={tempTitle}
685
- onChange={(e) => setTempTitle(e.target.value)}
686
- onBlur={() => handleBlur('title')}
687
- onKeyDown={(e) => handleKeyDown(e, 'title')}
688
- className="neo-border outline-none"
689
- style={{
690
- fontFamily: styles.fonts.heading,
691
- fontSize: '26px',
692
- fontWeight: 900,
693
- color: '#fff',
694
- backgroundColor: '#000',
695
- textTransform: 'uppercase',
696
- padding: '6px 18px',
697
- }}
698
- autoFocus
699
- />
700
- ) : (
701
- <h2
702
- className={`neo-border ${isEditable ? 'cursor-pointer' : ''}`}
703
- style={{
704
- fontFamily: styles.fonts.heading,
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: '11px',
734
  fontWeight: 900,
735
- padding: '2px 8px',
736
- letterSpacing: '0.04em',
 
 
 
 
 
 
 
 
 
737
  }}
738
  >
739
- [{String(i + 1).padStart(2, '0')}]
740
- </span>
 
 
 
741
 
742
- {editingField?.field === 'items' && editingField?.index === i ? (
743
- <input
744
- type="text"
745
- value={tempItems[i] || ''}
746
- onChange={(e) => {
747
- const next = [...tempItems];
748
- next[i] = e.target.value;
749
- setTempItems(next);
750
- }}
751
- onBlur={() => handleBlur('items', i)}
752
- onKeyDown={(e) => handleKeyDown(e, 'items', i)}
753
- className="flex-1 bg-transparent outline-none"
754
- style={{
755
- fontFamily: styles.fonts.body,
756
- fontSize: '14px',
757
- color: '#000',
758
- }}
759
- autoFocus
760
- />
761
- ) : (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
762
  <span
763
- className={`flex-1 ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
764
  style={{
765
- fontFamily: styles.fonts.body,
766
- fontSize: '14px',
767
- color: '#000',
768
- lineHeight: 1.5,
769
- }}
770
- onClick={() => {
771
- if (isEditable) {
772
- setEditingField({ field: 'items', index: i });
773
- setTempItems(items.map((x) => x.text));
774
- }
775
  }}
776
  >
777
- {item.text}
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') { e.preventDefault(); handleBlur(field); }
 
 
 
813
  if (e.key === 'Escape') {
814
  setEditingField(null);
815
- if (field === 'title') setTempTitle(title);
816
- if (field === 'subtitle') setTempSubtitle(subtitle || '');
817
  }
818
  };
819
 
820
  return (
821
  <div
822
- className="w-full h-full flex flex-col items-center justify-center relative overflow-hidden"
823
- style={{ backgroundColor: '#D9FF00', color: '#000' }}
 
824
  >
825
  <DotOverlay pattern={styles.dotPattern} />
826
 
827
- <div className="relative z-10 flex flex-col items-center text-center">
828
- {/* THE_END badge */}
829
- <div
830
- className="absolute -top-8 -left-10 bg-neo-purple neo-border"
831
- style={{
832
- transform: 'rotate(-12deg)',
833
- padding: '4px 14px',
834
- }}
 
835
  >
836
- <span
837
- style={{
838
- fontFamily: styles.fonts.heading,
839
- fontSize: '11px',
840
- fontWeight: 900,
841
- color: '#fff',
842
- textTransform: 'uppercase',
843
- letterSpacing: '0.08em',
844
- }}
845
- >
846
- THE_END
847
- </span>
848
- </div>
849
 
850
- {/* Main title */}
851
- <div
852
- >
853
- {editingField === 'title' ? (
854
- <input
855
- type="text"
856
- value={tempTitle}
857
- onChange={(e) => setTempTitle(e.target.value)}
858
- onBlur={() => handleBlur('title')}
859
- onKeyDown={(e) => handleKeyDown(e, 'title')}
860
- className="neo-border neo-shadow text-center outline-none"
861
- style={{
862
- fontFamily: styles.fonts.heading,
863
- fontSize: '52px',
864
- fontWeight: 900,
865
- color: '#000',
866
- textTransform: 'uppercase',
867
- padding: '10px 36px',
868
- backgroundColor: '#fff',
869
- letterSpacing: '-0.02em',
870
- width: '100%',
871
- }}
872
- autoFocus
873
- />
874
- ) : (
875
- <h1
876
- className={`neo-border neo-shadow ${isEditable ? 'cursor-pointer' : ''}`}
877
- style={{
878
- fontFamily: styles.fonts.heading,
879
- fontSize: '52px',
880
- fontWeight: 900,
881
- color: '#000',
882
- textTransform: 'uppercase',
883
- padding: '10px 36px',
884
- backgroundColor: '#fff',
885
- display: 'inline-block',
886
- letterSpacing: '-0.02em',
887
- }}
888
- onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
889
- >
890
- {title || 'THANK YOU!'}
891
- </h1>
892
- )}
893
- </div>
 
894
 
895
- {/* Subtitle */}
896
- {(subtitle || isEditable) && (
897
- <div
898
- className="mt-4"
899
- >
 
 
 
 
900
  {editingField === 'subtitle' ? (
901
- <input
902
- type="text"
903
  value={tempSubtitle}
904
  onChange={(e) => setTempSubtitle(e.target.value)}
905
  onBlur={() => handleBlur('subtitle')}
906
  onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
907
- className="text-center outline-none bg-transparent"
 
908
  style={{
909
  fontFamily: styles.fonts.body,
910
- fontSize: '14px',
911
- fontWeight: 700,
912
  color: '#000',
 
 
913
  textTransform: 'uppercase',
914
- letterSpacing: '0.12em',
915
- width: '100%',
916
  }}
 
917
  autoFocus
918
  />
919
  ) : (
920
  <p
921
- className={`${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
922
  style={{
923
  fontFamily: styles.fonts.body,
924
- fontSize: '14px',
925
- fontWeight: 700,
926
  color: '#000',
 
 
927
  textTransform: 'uppercase',
928
- letterSpacing: '0.12em',
 
 
 
 
 
 
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, { useState } from '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
- // NOISE OVERLAY
24
- // ============================================================================
25
-
26
- const NoiseOverlay = () => (
27
- <div className="absolute inset-0 pointer-events-none z-0 opacity-60 mix-blend-overlay">
28
- <svg className="w-full h-full">
29
- <filter id="noise-filter">
30
- <feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" />
31
- <feColorMatrix type="saturate" values="0" />
32
- </filter>
33
- <rect width="100%" height="100%" filter="url(#noise-filter)" />
34
- </svg>
35
- </div>
36
- );
37
-
38
- // ============================================================================
39
- // 1. NoisyTitleSubtitle
40
- // ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  export function NoisyTitleSubtitle({
43
  title,
44
  subtitle,
 
45
  styles,
46
  slideId,
47
  isEditable = false,
48
  onFieldUpdate,
 
49
  }: LayoutProps) {
50
- const [editingField, setEditingField] = useState<string | null>(null);
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
- className="w-full h-full flex flex-col justify-center relative overflow-hidden p-10"
 
73
  style={{ backgroundColor: '#547BEE' }}
74
  >
75
  <NoiseOverlay />
76
 
77
- <div className="relative z-10">
78
- {/* Title */}
79
- {editingField === 'title' ? (
80
- <input
81
- type="text"
82
- value={tempTitle}
83
- onChange={(e) => setTempTitle(e.target.value)}
84
- onBlur={() => handleBlur('title')}
85
- onKeyDown={(e) => handleKeyDown(e, 'title')}
86
- className="w-full bg-transparent outline-none font-mono font-bold underline decoration-2 underline-offset-4 mb-4"
87
- style={{ fontSize: '36px', color: '#ffffff' }}
88
- autoFocus
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- editingField === 'subtitle' ? (
103
- <input
104
- type="text"
105
- value={tempSubtitle}
106
- onChange={(e) => setTempSubtitle(e.target.value)}
107
- onBlur={() => handleBlur('subtitle')}
108
- onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
109
- className="w-full bg-transparent outline-none font-mono"
110
- style={{ fontSize: '16px', color: '#ffffff', opacity: 0.85 }}
111
- autoFocus
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
141
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-col relative overflow-hidden p-10"
 
164
  style={{ backgroundColor: '#547BEE' }}
165
  >
166
  <NoiseOverlay />
167
 
168
- <div className="relative z-10 flex flex-col h-full">
169
- {/* Title */}
170
- {editingField?.field === 'title' ? (
171
- <input
172
- type="text"
173
- value={tempTitle}
174
- onChange={(e) => setTempTitle(e.target.value)}
175
- onBlur={() => handleBlur('title')}
176
- onKeyDown={(e) => handleKeyDown(e, 'title')}
177
- className="bg-transparent outline-none font-mono font-bold underline decoration-2 underline-offset-4 mb-8"
178
- style={{ fontSize: '28px', color: '#ffffff' }}
179
- autoFocus
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  />
181
- ) : (
182
- <h2
183
- className={`font-mono font-bold underline decoration-2 underline-offset-4 mb-8 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
184
- style={{ fontSize: '28px', color: '#ffffff' }}
185
- onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
186
- >
187
- {title}
188
- </h2>
189
- )}
190
-
191
- {/* Agenda Items - horizontal row */}
192
- <div className="flex flex-row items-start justify-center gap-8 flex-1">
193
- {items.map((item, i) => (
194
- <div key={i} className="flex flex-col items-center text-center" style={{ minWidth: '120px', maxWidth: '180px' }}>
195
- {/* Big number */}
 
 
196
  <span
197
- className="font-mono font-bold"
198
- style={{ fontSize: '64px', color: '#ffffff', lineHeight: 1 }}
 
 
 
 
 
 
199
  >
200
- {String(i + 1).padStart(2, '0')}
201
  </span>
202
 
203
- {/* Coral accent bar */}
204
- <div
205
- className="my-3"
206
- style={{ width: '40px', height: '4px', backgroundColor: '#FF7A59', borderRadius: '2px' }}
 
 
 
 
 
 
 
 
 
 
 
 
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
- // 3. NoisyTitleAndText
244
- // ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
  export function NoisyTitleAndText({
247
  title,
248
  body = [],
 
249
  styles,
250
  slideId,
251
  isEditable = false,
252
  onFieldUpdate,
 
253
  }: LayoutProps) {
254
- const [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
255
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-col relative overflow-hidden p-10"
278
- style={{ backgroundColor: '#ffffff' }}
279
  >
280
- <div className="relative z-10 flex flex-col h-full">
281
- {/* Title */}
282
- {editingField?.field === 'title' ? (
283
- <input
284
- type="text"
285
- value={tempTitle}
286
- onChange={(e) => setTempTitle(e.target.value)}
287
- onBlur={() => handleBlur('title')}
288
- onKeyDown={(e) => handleKeyDown(e, 'title')}
289
- className="bg-transparent outline-none font-mono font-bold underline decoration-2 underline-offset-4 mb-6"
290
- style={{ fontSize: '28px', color: '#547BEE' }}
291
- autoFocus
292
- />
293
- ) : (
294
- <h2
295
- className={`font-mono font-bold underline decoration-2 underline-offset-4 mb-6 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
296
- style={{ fontSize: '28px', color: '#547BEE' }}
297
- onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  >
299
- {title}
300
- </h2>
301
- )}
302
-
303
- {/* Body Points */}
304
- <div className="flex flex-col gap-4 flex-1">
305
- {body.map((b, i) => (
306
- <div key={i} className="flex items-start gap-3">
307
- {/* Heading */}
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
- {b.text}
345
- </p>
346
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  </div>
348
- ))}
349
- </div>
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 [editingField, setEditingField] = useState<string | null>(null);
369
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-col relative overflow-hidden"
 
391
  style={{ backgroundColor: '#F2725C' }}
392
  >
393
  <NoiseOverlay />
394
 
395
- <div className="relative z-10 flex flex-col h-full p-10">
396
- {/* Title */}
397
- {editingField === 'title' ? (
398
- <input
399
- type="text"
400
- value={tempTitle}
401
- onChange={(e) => setTempTitle(e.target.value)}
402
- onBlur={() => handleBlur('title')}
403
- onKeyDown={(e) => handleKeyDown(e, 'title')}
404
- className="bg-transparent outline-none font-mono font-bold underline decoration-2 underline-offset-4 mb-6"
405
- style={{ fontSize: '28px', color: '#ffffff' }}
406
- autoFocus
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  />
408
- ) : (
409
- <h2
410
- className={`font-mono font-bold underline decoration-2 underline-offset-4 mb-6 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
411
- style={{ fontSize: '28px', color: '#ffffff' }}
412
- onClick={() => { if (isEditable) { setEditingField('title'); setTempTitle(title); } }}
413
- >
414
- {title}
415
- </h2>
416
- )}
417
-
418
- {/* Two columns: image + text */}
419
- <div className="flex flex-row gap-8 flex-1 min-h-0">
420
- {/* Left: Image */}
421
- <div className="w-1/2 h-full rounded overflow-hidden flex items-center justify-center" style={{ backgroundColor: 'rgba(255,255,255,0.15)' }}>
422
- {imageUrl ? (
423
- <img
424
- src={imageUrl}
425
- alt={title}
426
- className="w-full h-full object-cover"
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
- {/* Right: Text */}
439
- <div className="w-1/2 flex flex-col justify-center">
440
- {editingField === 'body' ? (
441
- <textarea
442
- value={tempBody}
443
- onChange={(e) => setTempBody(e.target.value)}
444
- onBlur={() => handleBlur('body')}
445
- onKeyDown={(e) => handleKeyDown(e, 'body')}
446
- className="w-full bg-transparent outline-none resize-none font-mono"
447
- style={{ fontSize: '14px', color: '#ffffff', lineHeight: 1.7 }}
448
- rows={6}
449
- autoFocus
450
- />
451
- ) : (
452
- <p
453
- className={`font-mono ${isEditable ? 'cursor-pointer hover:opacity-70' : ''}`}
454
- style={{ fontSize: '14px', color: '#ffffff', lineHeight: 1.7, opacity: 0.95 }}
455
- onClick={() => { if (isEditable) { setEditingField('body'); setTempBody(body[0]?.text || ''); } }}
456
- >
457
- {body[0]?.text || (isEditable ? 'Click to add description' : '')}
458
- </p>
459
- )}
460
- </div>
 
 
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 [editingField, setEditingField] = useState<{ field: string; index?: number } | null>(null);
480
- const [tempTitle, setTempTitle] = useState(title);
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
- className="w-full h-full flex flex-col relative overflow-hidden p-10"
503
- style={{ backgroundColor: '#ffffff' }}
504
  >
505
- <div className="relative z-10 flex flex-col h-full">
506
- {/* Title */}
507
- {editingField?.field === 'title' ? (
508
- <input
509
- type="text"
510
- value={tempTitle}
511
- onChange={(e) => setTempTitle(e.target.value)}
512
- onBlur={() => handleBlur('title')}
513
- onKeyDown={(e) => handleKeyDown(e, 'title')}
514
- className="bg-transparent outline-none font-mono font-bold underline decoration-2 underline-offset-4 mb-6"
515
- style={{ fontSize: '28px', color: '#547BEE' }}
516
- autoFocus
517
- />
518
- ) : (
519
- <h2
520
- className={`font-mono font-bold underline decoration-2 underline-offset-4 mb-6 ${isEditable ? 'cursor-pointer hover:opacity-80' : ''}`}
521
- style={{ fontSize: '28px', color: '#547BEE' }}
522
- onClick={() => { if (isEditable) { setEditingField({ field: 'title' }); setTempTitle(title); } }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
523
  >
524
- {title}
525
- </h2>
526
- )}
527
-
528
- {/* Reference Items */}
529
- <div className="flex flex-col gap-3 flex-1">
530
- {items.map((item, i) => (
531
- <div
532
- key={i}
533
- className="flex items-start gap-3 py-2"
534
- style={{ borderBottom: '1px solid #e5e7eb' }}
535
- >
536
- {/* Bullet dot */}
537
- <div
538
- className="shrink-0 mt-2 rounded-full"
539
- style={{ width: '6px', height: '6px', backgroundColor: '#547BEE' }}
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
- </div>
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 [editingField, setEditingField] = useState<string | null>(null);
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
- className="w-full h-full flex flex-col items-center justify-center relative overflow-hidden p-10"
 
609
  style={{ backgroundColor: '#547BEE' }}
610
  >
611
  <NoiseOverlay />
612
 
613
- <div className="relative z-10 text-center">
614
- {/* Title */}
615
- {editingField === 'title' ? (
616
- <input
617
- type="text"
618
- value={tempTitle}
619
- onChange={(e) => setTempTitle(e.target.value)}
620
- onBlur={() => handleBlur('title')}
621
- onKeyDown={(e) => handleKeyDown(e, 'title')}
622
- className="w-full text-center bg-transparent outline-none font-mono font-bold mb-4"
623
- style={{ fontSize: '48px', color: '#ffffff' }}
624
- autoFocus
 
 
 
 
 
 
 
 
 
 
 
 
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
- editingField === 'subtitle' ? (
639
- <input
640
- type="text"
641
- value={tempSubtitle}
642
- onChange={(e) => setTempSubtitle(e.target.value)}
643
- onBlur={() => handleBlur('subtitle')}
644
- onKeyDown={(e) => handleKeyDown(e, 'subtitle')}
645
- className="w-full text-center bg-transparent outline-none font-mono"
646
- style={{ fontSize: '20px', color: '#ffffff', opacity: 0.85 }}
647
- autoFocus
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- { key: 'subtitle', label: 'Subtitle', type: 'text', defaultValue: 'Visual storytelling and unique moments captured elegantly with refined minimalism.' },
 
 
 
 
 
 
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
- { key: 'body', label: 'Description', type: 'text', defaultValue: 'We are creating engaging content using design philosophies that improve brand awareness and customer engagement.' },
76
- { key: 'imageUrl', label: 'Image URL', type: 'image', defaultValue: '' },
 
 
 
 
 
 
 
 
 
 
 
 
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 Brutalism',
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 INSIGHT' },
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: 'REFERENCES & SOURCES' },
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: 'Noisy',
6
- description: 'Bold monospace aesthetic with noise texture overlay and vibrant colors',
7
  thumbnail: '📡',
8
  styles: {
9
  colors: {
@@ -15,8 +15,8 @@ export const noisyTemplate: Template = {
15
  cardBg: '#ffffff',
16
  },
17
  fonts: {
18
- heading: "'Roboto Mono', monospace",
19
- body: "'Roboto Mono', monospace",
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, TEMPLATE_THEMES } from '@/lib/editor-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 sanitizeColorsForExport(clonedDoc: Document) {
22
- const style = clonedDoc.createElement('style');
23
- style.textContent = `
24
- * { transition: none !important; animation: none !important; }
25
- .dark * { background-color: #242424 !important; color: #fafafa !important; }
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, currentTheme, currentSlideIndex, zoom,
47
- selectedId, isEditingTextId, presentationTitle,
48
- setCurrentSlideIndex, setZoom, setSelectedId, setIsEditingTextId,
 
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 new Promise(resolve => setTimeout(resolve, 300));
 
61
 
62
  const images: string[] = [];
63
- for (let i = 0; i < slides.length; i++) {
64
- setCurrentSlideIndex(i);
65
- await new Promise(resolve => setTimeout(resolve, 400));
 
 
66
  if (!slideRef.current) continue;
67
 
68
- const canvas = await html2canvas(slideRef.current, {
69
- backgroundColor: null,
70
- scale: 2,
71
- logging: false,
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 captureAllSlides();
106
  const pdf = new jsPDF({ orientation: 'landscape', unit: 'px', format: [800, 450] });
107
- images.forEach((imgData, i) => {
108
- if (i > 0) pdf.addPage();
109
- pdf.addImage(imgData, 'PNG', 0, 0, 800, 450);
 
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
- const images = await captureAllSlides();
123
- const pptxgen = (await import('pptxgenjs')).default;
124
- const pres = new pptxgen();
125
- pres.defineLayout({ name: 'LAYOUT_16x9', width: 10, height: 5.625 });
126
- pres.layout = 'LAYOUT_16x9';
127
- for (const imgData of images) {
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
- let colonIndex = activeModel.indexOf(":", activeModel.indexOf("/") + 1);
58
- let hasEncodedProvider = colonIndex !== -1;
59
- let modelId = hasEncodedProvider ? activeModel.substring(0, colonIndex) : activeModel;
60
- let provider = hasEncodedProvider ? activeModel.substring(colonIndex + 1) : "fireworks-ai";
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
- console.error("Error generating slide content:", error);
81
- throw new Error("Failed to generate slide content");
 
 
 
 
 
 
 
 
 
 
 
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
- thumb: string;
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
- // Call our API endpoint to search Unsplash
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 (data.results && data.results.length > 0) {
62
- const image: UnsplashImage = data.results[0];
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 NO generic placeholders
37
- 4. Each bullet point: 15-25 words with concrete information
38
  5. Generate relevant imageKeyword for Unsplash (2-4 descriptive terms)
39
 
40
- OUTPUT FORMAT Return ONLY valid JSON matching this exact structure:
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": ["Detailed explanation with specific information (15-25 words)", "Another point with evidence or examples (15-25 words)"],
59
  "layout": "title_and_text",
60
  "imageKeyword": ""
61
  },
 
 
 
 
 
 
 
 
 
 
62
  {
63
  "title": "Visual Topic Title",
64
- "content": ["Description of the visual content and its relevance (15-25 words)"],
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" Opening slide with title and subtitle
92
- - "agenda" Table of contents / agenda items
93
- - "title_and_text" Text-heavy content slide (no image needed)
94
- - "image_and_text" Content slide with image (MUST have imageKeyword)
95
- - "references" Sources and citations
96
- - "thank_you" Closing slide
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
- 'titleContent': 'title_subtitle',
134
- 'title': 'title_subtitle',
135
- 'titleContentImage': 'image_and_text',
136
  'content-image': 'image_and_text',
137
- 'twoContent': 'title_and_text',
138
  'two-column': 'title_and_text',
139
- 'bullets': 'title_and_text',
140
- 'section': 'title_and_text',
141
- 'chart': 'title_and_text',
 
 
 
 
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 Brutalism',
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: 'Noisy',
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
- "html2canvas": "^1.4.1",
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",