feat: add slides generation tool with multiple slide types
- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1183,6 +1183,19 @@ RULES:
|
||||
})
|
||||
|
||||
console.log('[Excalidraw Tool] Canvas created:', canvas.id, canvas.name)
|
||||
|
||||
// Immediately mark the AgentAction as success so frontend polling unblocks
|
||||
if (ctx.actionId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Diagram generated: ${elements.length} elements`,
|
||||
},
|
||||
}).catch(err => console.error('[Excalidraw Tool] Failed to update action status:', err))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true, canvasId: canvas.id, canvasName: canvas.name,
|
||||
elementCount: elements.length,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Note CRUD Tools
|
||||
* note_create, note_read, note_update
|
||||
* note_create, note_read, note_update, note_find_and_update
|
||||
*/
|
||||
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||
@@ -104,3 +105,85 @@ toolRegistry.register({
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// --- note_find_and_update ---
|
||||
toolRegistry.register({
|
||||
name: 'note_find_and_update',
|
||||
description: 'Find a note by searching its title/content, then append, prepend, or replace information in it. Use this when the user says "find the note about X and add Y to it".',
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: 'Find a note by a search query, then update its content (append/prepend/replace).',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Search query to find the note (e.g. "bugs and new features")'),
|
||||
newContent: z.string().describe('Content to add to the note (markdown supported)'),
|
||||
operation: z.enum(['append', 'prepend', 'replace']).default('append').describe('append: add to end, prepend: add to start, replace: overwrite'),
|
||||
}),
|
||||
execute: async ({ query, newContent, operation }) => {
|
||||
try {
|
||||
// FTS search for best matching note
|
||||
const results = await prisma.$queryRaw<Array<{ id: string; title: string | null; content: string | null }>>(
|
||||
Prisma.sql`
|
||||
SELECT id, title, content
|
||||
FROM "Note"
|
||||
WHERE "tsv" @@ plainto_tsquery('simple', ${query})
|
||||
AND "trashedAt" IS NULL
|
||||
AND "isArchived" = false
|
||||
AND "userId" = ${ctx.userId}
|
||||
ORDER BY ts_rank("tsv", plainto_tsquery('simple', ${query})) DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
// Fallback: simple title/content ILIKE search
|
||||
const fallback = await prisma.note.findFirst({
|
||||
where: {
|
||||
userId: ctx.userId,
|
||||
trashedAt: null,
|
||||
isArchived: false,
|
||||
OR: [
|
||||
{ title: { contains: query, mode: 'insensitive' } },
|
||||
{ content: { contains: query, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: { id: true, title: true, content: true },
|
||||
})
|
||||
if (!fallback) {
|
||||
return { error: `No note found matching "${query}". Try a different search term.` }
|
||||
}
|
||||
results.push(fallback)
|
||||
}
|
||||
|
||||
const note = results[0]
|
||||
let updatedContent: string
|
||||
|
||||
switch (operation) {
|
||||
case 'append':
|
||||
updatedContent = note.content ? `${note.content}\n\n${newContent}` : newContent
|
||||
break
|
||||
case 'prepend':
|
||||
updatedContent = note.content ? `${newContent}\n\n${note.content}` : newContent
|
||||
break
|
||||
case 'replace':
|
||||
default:
|
||||
updatedContent = newContent
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: note.id },
|
||||
data: { content: updatedContent, updatedAt: new Date() },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title || 'Untitled',
|
||||
operation,
|
||||
message: `Successfully updated note "${note.title || 'Untitled'}" (${operation}).`,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { error: `find_and_update failed: ${e.message}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -52,7 +52,7 @@ class ToolRegistry {
|
||||
* When webOnly is true, only web tools are included (no note access).
|
||||
*/
|
||||
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
|
||||
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'document_search', 'task_extract']
|
||||
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'note_find_and_update', 'document_search', 'task_extract']
|
||||
|
||||
// Add web tools only when user toggled web search AND config is present
|
||||
if (ctx.webSearch) {
|
||||
|
||||
463
memento-note/lib/ai/tools/slides-html-builder.ts
Normal file
463
memento-note/lib/ai/tools/slides-html-builder.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Server-side HTML renderer for presentations.
|
||||
* Takes a structured JSON spec and produces a complete standalone HTML file.
|
||||
* The AI only needs to output data — this module handles all the visual rendering.
|
||||
*/
|
||||
|
||||
// ── Recipe definitions ──────────────────────────────────────────────────────
|
||||
interface Recipe {
|
||||
bg: string; text: string; textSecondary: string; textMuted: string
|
||||
accent1: string; accent2: string
|
||||
glassBg: string; glassBorder: string; svgGrid: string
|
||||
fontDisplay: string; fontBody: string; fontUrl: string
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
const RECIPES: Record<string, Recipe> = {
|
||||
'midnight-cathedral': { bg: '#0a0f1e', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#C9A84C', accent2: '#E8D5B5', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: true },
|
||||
'aurora-borealis': { bg: '#0f0a2a', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#7C3AED', accent2: '#06B6D4', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Instrument Serif'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true },
|
||||
'tokyo-neon': { bg: '#0a0a0f', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#FF006E', accent2: '#3A86FF', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Bebas Neue'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@300;400;600;700;900&display=swap', isDark: true },
|
||||
'sunlit-gallery': { bg: '#FAF7F0', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#D4A574', accent2: '#5B9BD5', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Abril Fatface'", fontBody: "'Epilogue'", fontUrl: 'https://fonts.googleapis.com/css2?family=Abril+Fatface&family=Epilogue:wght@300;400;600;700;900&display=swap', isDark: false },
|
||||
'clinical-precision': { bg: '#F8FAFC', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#0891B2', accent2: '#34D399', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Manrope'", fontBody: "'Manrope'", fontUrl: 'https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;600;700;800;900&display=swap', isDark: false },
|
||||
'venture-pitch': { bg: '#18181B', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#F97316', accent2: '#14B8A6', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Archivo Black'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Archivo+Black&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: true },
|
||||
'forest-floor': { bg: '#0D1B0E', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#22C55E', accent2: '#A3B18A', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Outfit'", fontBody: "'Outfit'", fontUrl: 'https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700;800;900&display=swap', isDark: true },
|
||||
'steel-glass': { bg: '#292524', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#D4C5A9', accent2: '#94A3B8', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'Prata'", fontBody: "'Work Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Prata&family=Work+Sans:wght@300;400;600;700&display=swap', isDark: true },
|
||||
'cyberpunk-terminal': { bg: '#0A0A0A', text: '#f1f5f9', textSecondary: '#cbd5e1', textMuted: '#94a3b8', accent1: '#00FF41', accent2: '#FFA500', glassBg: 'rgba(255,255,255,0.06)', glassBorder: 'rgba(255,255,255,0.10)', svgGrid: 'rgba(255,255,255,0.06)', fontDisplay: "'JetBrains Mono'", fontBody: "'JetBrains Mono'", fontUrl: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;600;700;800&display=swap', isDark: true },
|
||||
'editorial-ink': { bg: '#FFFCF5', text: '#1A1A2E', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1A1A2E', accent2: '#800020', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'DM Serif Display'", fontBody: "'DM Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;700&display=swap', isDark: false },
|
||||
'coastal-morning': { bg: '#F0F7FF', text: '#0F172A', textSecondary: '#475569', textMuted: '#64748B', accent1: '#2563EB', accent2: '#F97066', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Plus Jakarta Sans'", fontBody: "'Plus Jakarta Sans'", fontUrl: 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;600;700;800&display=swap', isDark: false },
|
||||
'paper-studio': { bg: '#FEFCF8', text: '#1E293B', textSecondary: '#475569', textMuted: '#64748B', accent1: '#1E293B', accent2: '#C2410C', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Literata'", fontBody: "'Source Sans 3'", fontUrl: 'https://fonts.googleapis.com/css2?family=Literata:wght@400;700;900&family=Source+Sans+3:wght@300;400;600;700&display=swap', isDark: false },
|
||||
'architectural-saas': { bg: '#F2F0E9', text: '#1C1C1C', textSecondary: '#475569', textMuted: '#64748B', accent1: '#A47148', accent2: '#4A4E69', glassBg: 'rgba(0,0,0,0.04)', glassBorder: 'rgba(0,0,0,0.10)', svgGrid: 'rgba(0,0,0,0.06)', fontDisplay: "'Playfair Display'", fontBody: "'Inter'", fontUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@300;400;600;700;900&display=swap', isDark: false },
|
||||
}
|
||||
|
||||
function resolveRecipe(name?: string): Recipe {
|
||||
if (!name || name === 'auto') return RECIPES['architectural-saas']
|
||||
const key = name.toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
return RECIPES[key] ?? RECIPES['architectural-saas']
|
||||
}
|
||||
|
||||
// ── Slide types ─────────────────────────────────────────────────────────────
|
||||
export interface SlideTitle { type: 'title'; title: string; subtitle?: string }
|
||||
export interface SlideBullets { type: 'bullets'; title: string; items: string[] }
|
||||
export interface SlideChart { type: 'chart'; title: string; chartType: 'bar' | 'horizontal-bar' | 'line' | 'donut' | 'radar'; data: { label: string; value: number; color?: string }[]; subtitle?: string }
|
||||
export interface SlideStats { type: 'stats'; title: string; stats: { value: string; label: string }[] }
|
||||
export interface SlideTable { type: 'table'; title: string; headers: string[]; rows: string[][] }
|
||||
export interface SlideCards { type: 'cards'; title: string; cards: { title: string; description: string }[] }
|
||||
export interface SlideTimeline { type: 'timeline'; title: string; events: { date: string; title: string; description?: string }[] }
|
||||
export interface SlideQuote { type: 'quote'; quote: string; author?: string; context?: string }
|
||||
export interface SlideComparison { type: 'comparison'; title: string; left: { title: string; points: string[]; score?: string }; right: { title: string; points: string[]; score?: string } }
|
||||
export interface SlideEquation { type: 'equation'; title: string; equations: { latex: string; label?: string }[]; explanation?: string }
|
||||
export interface SlideImage { type: 'image'; title: string; url?: string; caption?: string }
|
||||
export interface SlideSummary { type: 'summary'; title: string; items: string[] }
|
||||
|
||||
export type SlideSpec = SlideTitle | SlideBullets | SlideChart | SlideStats | SlideTable | SlideCards | SlideTimeline | SlideQuote | SlideComparison | SlideEquation | SlideImage | SlideSummary
|
||||
|
||||
export interface PresentationInput {
|
||||
title: string
|
||||
theme?: string
|
||||
slides: SlideSpec[]
|
||||
}
|
||||
|
||||
// ── Individual slide renderers ──────────────────────────────────────────────
|
||||
function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>
|
||||
<div class="content" style="text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
|
||||
<div class="reveal" style="width:52px;height:5px;background:${r.accent1};border-radius:3px;margin-bottom:24px;"></div>
|
||||
<h1 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(2.8rem,5vw,4.5rem);font-weight:900;line-height:1.05;letter-spacing:-0.04em;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin:0;max-width:900px;">${esc(slide.title)}</h1>
|
||||
${slide.subtitle ? `<p class="reveal" style="font-size:clamp(1rem,2vw,1.4rem);color:${r.textSecondary};margin-top:20px;max-width:640px;line-height:1.5;">${esc(slide.subtitle)}</p>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
|
||||
const items = slide.items.map((item, i) => `
|
||||
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
|
||||
<span style="font-size:1.1rem;line-height:1.65;color:${r.text};">${esc(item)}</span>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
|
||||
const chartHtml = (() => {
|
||||
switch (slide.chartType) {
|
||||
case 'bar': return renderBarChart(slide.data, r)
|
||||
case 'horizontal-bar': return renderHBarChart(slide.data, r)
|
||||
case 'line': return renderLineChart(slide.data, r)
|
||||
case 'donut': return renderDonutChart(slide.data, r)
|
||||
case 'radar': return renderRadarChart(slide.data, r)
|
||||
default: return renderBarChart(slide.data, r)
|
||||
}
|
||||
})()
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0;">${esc(slide.title)}</h2>
|
||||
${slide.subtitle ? `<p class="reveal" style="font-size:0.95rem;color:${r.textMuted};margin:6px 0 0;">${esc(slide.subtitle)}</p>` : ''}
|
||||
<div class="reveal" style="margin-top:32px;">${chartHtml}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
||||
const cols = Math.min(slide.stats.length, 4)
|
||||
const items = slide.stats.map(s => `
|
||||
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
|
||||
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
|
||||
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
|
||||
const ths = slide.headers.map(h => `<th style="padding:12px 16px;text-align:left;font-size:0.75rem;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${r.accent1};border-bottom:2px solid ${r.accent1};">${esc(h)}</th>`).join('')
|
||||
const rows = slide.rows.map((row, ri) => {
|
||||
const tds = row.map(cell => `<td style="padding:11px 16px;font-size:0.9rem;color:${r.text};border-bottom:1px solid ${r.glassBorder};">${esc(cell)}</td>`).join('')
|
||||
return `<tr style="background:${ri % 2 === 0 ? 'transparent' : r.glassBg};">${tds}</tr>`
|
||||
}).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:24px;"></div>
|
||||
<div class="reveal" style="overflow:auto;border-radius:12px;border:1px solid ${r.glassBorder};">
|
||||
<table style="width:100%;border-collapse:collapse;"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
|
||||
const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
|
||||
const items = slide.cards.map((c, i) => `
|
||||
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;">
|
||||
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
|
||||
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
|
||||
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
||||
<div style="font-size:0.88rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:16px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderTimeline(slide: SlideTimeline, r: Recipe, idx: number): string {
|
||||
const items = slide.events.map((ev, i) => `
|
||||
<div class="reveal" style="display:flex;align-items:flex-start;gap:20px;position:relative;padding-left:20px;">
|
||||
<div style="position:absolute;left:0;top:8px;width:12px;height:12px;border-radius:50%;background:${r.accent1};border:3px solid ${r.bg};z-index:1;"></div>
|
||||
<div>
|
||||
<span style="font-size:0.7rem;font-weight:700;letter-spacing:0.15em;text-transform:uppercase;color:${r.accent1};">${esc(ev.date)}</span>
|
||||
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-top:2px;">${esc(ev.title)}</div>
|
||||
${ev.description ? `<div style="font-size:0.85rem;color:${r.textSecondary};margin-top:4px;line-height:1.5;">${esc(ev.description)}</div>` : ''}
|
||||
</div>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:20px;border-left:2px solid ${r.accent1};padding-left:8px;margin-left:6px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderQuote(slide: SlideQuote, r: Recipe, idx: number): string {
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content" style="display:flex;flex-direction:column;justify-content:center;height:100%;">
|
||||
<div class="reveal" style="font-size:5rem;color:${r.accent1};opacity:0.4;line-height:0.6;font-family:Georgia,serif;">"</div>
|
||||
<blockquote class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.5rem,3vw,2.2rem);font-weight:600;font-style:italic;line-height:1.4;letter-spacing:-0.02em;color:${r.text};margin:16px 0 0;max-width:860px;">${esc(slide.quote)}</blockquote>
|
||||
${slide.author ? `<div class="reveal" style="display:flex;align-items:center;gap:12px;margin-top:28px;"><div style="width:36px;height:2px;background:${r.accent1};"></div><span style="font-size:0.85rem;font-weight:700;text-transform:uppercase;letter-spacing:0.12em;color:${r.accent1};">— ${esc(slide.author)}</span></div>` : ''}
|
||||
${slide.context ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};margin-top:20px;line-height:1.7;max-width:700px;">${esc(slide.context)}</p>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderComparison(slide: SlideComparison, r: Recipe, idx: number): string {
|
||||
const col = (side: { title: string; points: string[]; score?: string }, accent: string) => {
|
||||
const pts = side.points.map(p => `<li style="display:flex;gap:8px;font-size:0.88rem;color:${r.textSecondary};line-height:1.5;"><span style="color:${accent};font-weight:700;flex-shrink:0;">✓</span>${esc(p)}</li>`).join('')
|
||||
return `<div class="reveal" style="padding:28px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;border-top:3px solid ${accent};">
|
||||
<div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.2em;color:${accent};margin-bottom:14px;">${esc(side.title)}</div>
|
||||
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:10px;">${pts}</ul>
|
||||
${side.score ? `<div style="margin-top:20px;padding-top:16px;border-top:1px solid ${r.glassBorder};"><div style="font-size:1.5rem;font-weight:900;color:${accent};">${esc(side.score)}</div></div>` : ''}
|
||||
</div>`
|
||||
}
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">${col(slide.left, r.accent1)}${col(slide.right, r.accent2)}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
|
||||
const eqs = slide.equations.map(eq => `
|
||||
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
|
||||
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
|
||||
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${esc(eq.label)}</div>` : ''}
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||||
${eqs}
|
||||
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${esc(slide.explanation)}</p>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderImage(slide: SlideImage, r: Recipe, idx: number): string {
|
||||
const img = slide.url
|
||||
? `<img src="${esc(slide.url)}" alt="${esc(slide.title)}" style="max-width:80%;max-height:60vh;border-radius:12px;object-fit:contain;" />`
|
||||
: `<div style="width:400px;height:250px;background:${r.glassBg};border:1px dashed ${r.glassBorder};border-radius:12px;display:flex;align-items:center;justify-content:center;color:${r.textMuted};font-size:0.9rem;">Image placeholder</div>`
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content" style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3vw,2.2rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal">${img}</div>
|
||||
${slide.caption ? `<p class="reveal" style="font-size:0.85rem;color:${r.textMuted};margin-top:16px;text-align:center;">${esc(slide.caption)}</p>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderSummary(slide: SlideSummary, r: Recipe, idx: number, isLast: boolean): string {
|
||||
const items = slide.items.map(item => `
|
||||
<div class="reveal" style="display:flex;align-items:center;gap:16px;padding:14px 20px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
|
||||
<div style="width:26px;height:26px;min-width:26px;background:${r.accent1};border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;color:#fff;font-weight:900;">✓</div>
|
||||
<span style="font-size:1rem;line-height:1.45;color:${r.text};">${esc(item)}</span>
|
||||
</div>`).join('')
|
||||
const canvas = isLast ? `<canvas id="particles-${idx}" style="position:absolute;inset:0;z-index:1;pointer-events:none;width:100%;height:100%;"></canvas>` : ''
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
${canvas}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
// ── Chart renderers ─────────────────────────────────────────────────────────
|
||||
function renderBarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const bars = data.map(d => {
|
||||
const pct = Math.round((d.value / max) * 100)
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:6px;flex:1;min-width:0;">
|
||||
<span style="font-size:0.75rem;font-weight:700;color:${r.textSecondary};">${d.value}</span>
|
||||
<div class="bar" data-height="${pct}" style="background:linear-gradient(to top,${r.accent1},${r.accent2});height:0%;width:100%;border-radius:6px 6px 0 0;"></div>
|
||||
<span style="font-size:0.7rem;color:${r.textMuted};text-align:center;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
|
||||
</div>`
|
||||
}).join('')
|
||||
return `<div style="display:flex;align-items:flex-end;gap:12px;height:200px;">${bars}</div>`
|
||||
}
|
||||
|
||||
function renderHBarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const bars = data.map(d => {
|
||||
const pct = Math.round((d.value / max) * 100)
|
||||
return `<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px;">
|
||||
<span style="width:100px;text-align:right;font-size:0.8rem;color:${r.textSecondary};flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(d.label)}</span>
|
||||
<div style="flex:1;background:${r.glassBg};border-radius:6px;height:28px;overflow:hidden;">
|
||||
<div class="bar-fill" data-width="${pct}" style="height:100%;border-radius:6px;width:0%;background:linear-gradient(to right,${r.accent1},${r.accent2});"></div>
|
||||
</div>
|
||||
<span style="width:40px;font-size:0.8rem;font-weight:700;color:${r.text};">${d.value}</span>
|
||||
</div>`
|
||||
}).join('')
|
||||
return `<div>${bars}</div>`
|
||||
}
|
||||
|
||||
function renderLineChart(data: { label: string; value: number }[], r: Recipe): string {
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const w = 600, h = 200, pad = 50
|
||||
const points = data.map((d, i) => {
|
||||
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
|
||||
const y = h - pad - ((d.value / max) * (h - 2 * pad))
|
||||
return `${x},${y}`
|
||||
})
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p}`).join(' ')
|
||||
const areaD = `${pathD} L${pad + ((data.length - 1) / Math.max(data.length - 1, 1)) * (w - 2 * pad)},${h - pad} L${pad},${h - pad} Z`
|
||||
const gridLines = [0.25, 0.5, 0.75].map(f => {
|
||||
const y = h - pad - f * (h - 2 * pad)
|
||||
return `<line x1="${pad}" y1="${y}" x2="${w - pad}" y2="${y}" stroke="${r.svgGrid}" stroke-width="1"/>`
|
||||
}).join('')
|
||||
const dots = data.map((d, i) => {
|
||||
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
|
||||
const y = h - pad - ((d.value / max) * (h - 2 * pad))
|
||||
return `<circle cx="${x}" cy="${y}" r="4" fill="${r.accent1}" stroke="${r.bg}" stroke-width="2"/>`
|
||||
}).join('')
|
||||
const labels = data.map((d, i) => {
|
||||
const x = pad + (i / Math.max(data.length - 1, 1)) * (w - 2 * pad)
|
||||
return `<text x="${x}" y="${h - 15}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
||||
}).join('')
|
||||
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto;">
|
||||
<defs><linearGradient id="lg-${Math.random().toString(36).slice(2, 6)}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${r.accent1}" stop-opacity="0.25"/><stop offset="100%" stop-color="${r.accent1}" stop-opacity="0"/></linearGradient></defs>
|
||||
${gridLines}
|
||||
<path fill="url(#lg-area)" d="${areaD}" opacity="0.4"/>
|
||||
<path class="line-path" d="${pathD}" stroke="${r.accent1}" fill="none" stroke-width="2.5" stroke-linecap="round"/>
|
||||
${dots}${labels}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
function renderDonutChart(data: { label: string; value: number }[], r: Recipe): string {
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1
|
||||
const colors = [r.accent1, r.accent2, '#10b981', '#f59e0b', '#ef4444', '#6366f1']
|
||||
let offset = 0
|
||||
const rings = data.map((d, i) => {
|
||||
const pct = (d.value / total) * 100
|
||||
const dashLen = (pct / 100) * 502
|
||||
const ring = `<circle cx="100" cy="100" r="80" fill="none" stroke="${colors[i % colors.length]}" stroke-width="20" stroke-dasharray="${dashLen} 502" stroke-dashoffset="${-offset}" transform="rotate(-90 100 100)"/>`
|
||||
offset += dashLen
|
||||
return ring
|
||||
}).join('')
|
||||
const legend = data.map((d, i) => `<div style="display:flex;align-items:center;gap:8px;"><div style="width:10px;height:10px;border-radius:50%;background:${colors[i % colors.length]};"></div><span style="font-size:0.8rem;color:${r.textSecondary};">${esc(d.label)} (${Math.round((d.value / total) * 100)}%)</span></div>`).join('')
|
||||
return `<div style="display:flex;align-items:center;gap:40px;justify-content:center;">
|
||||
<svg viewBox="0 0 200 200" style="width:180px;height:180px;">${rings}<text x="100" y="105" text-anchor="middle" font-size="22" font-weight="900" fill="${r.text}">${total}</text></svg>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;">${legend}</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderRadarChart(data: { label: string; value: number }[], r: Recipe): string {
|
||||
const n = data.length
|
||||
const cx = 150, cy = 150, radius = 110
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const angleStep = (2 * Math.PI) / n
|
||||
// Grid
|
||||
const gridLevels = [0.25, 0.5, 0.75, 1].map(f => {
|
||||
const pts = Array.from({ length: n }, (_, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
return `${cx + Math.cos(a) * radius * f},${cy + Math.sin(a) * radius * f}`
|
||||
}).join(' ')
|
||||
return `<polygon points="${pts}" fill="none" stroke="${r.svgGrid}" stroke-width="1"/>`
|
||||
}).join('')
|
||||
// Data polygon
|
||||
const dataPts = data.map((d, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
const r2 = (d.value / max) * radius
|
||||
return `${cx + Math.cos(a) * r2},${cy + Math.sin(a) * r2}`
|
||||
}).join(' ')
|
||||
// Labels
|
||||
const labels = data.map((d, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
const lx = cx + Math.cos(a) * (radius + 20)
|
||||
const ly = cy + Math.sin(a) * (radius + 20)
|
||||
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
||||
}).join('')
|
||||
return `<svg viewBox="0 0 300 300" style="width:100%;max-width:320px;height:auto;margin:0 auto;display:block;">
|
||||
${gridLevels}
|
||||
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.15" stroke="${r.accent1}" stroke-width="2"/>
|
||||
${labels}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function esc(s: string): string { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') }
|
||||
function mesh(r: Recipe): string {
|
||||
return `<div class="gradient-mesh"><div class="blob" style="width:500px;height:500px;top:-100px;right:-100px;background:${r.accent1};opacity:0.12;--dur:18s;"></div><div class="blob" style="width:300px;height:300px;bottom:-80px;left:-60px;background:${r.accent2};opacity:0.09;--dur:14s;"></div></div>`
|
||||
}
|
||||
function extractNum(s: string): string { const m = s.match(/[\d.]+/); return m ? m[0] : '0' }
|
||||
function extractSuffix(s: string): string { const m = s.match(/[\d.]+(.*)/); return m ? m[1].trim() : '' }
|
||||
|
||||
// ── Render a single slide by type ───────────────────────────────────────────
|
||||
function renderSlide(slide: SlideSpec, r: Recipe, idx: number, total: number): string {
|
||||
switch (slide.type) {
|
||||
case 'title': return renderTitle(slide, r, idx)
|
||||
case 'bullets': return renderBullets(slide, r, idx)
|
||||
case 'chart': return renderChart(slide, r, idx)
|
||||
case 'stats': return renderStats(slide, r, idx)
|
||||
case 'table': return renderTable(slide, r, idx)
|
||||
case 'cards': return renderCards(slide, r, idx)
|
||||
case 'timeline': return renderTimeline(slide, r, idx)
|
||||
case 'quote': return renderQuote(slide, r, idx)
|
||||
case 'comparison': return renderComparison(slide, r, idx)
|
||||
case 'equation': return renderEquation(slide, r, idx)
|
||||
case 'image': return renderImage(slide, r, idx)
|
||||
case 'summary': return renderSummary(slide, r, idx, idx === total)
|
||||
default: return renderBullets({ type: 'bullets', title: (slide as any).title || 'Slide', items: (slide as any).items || (slide as any).content || ['Content'] }, r, idx)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main export: build full HTML from spec ──────────────────────────────────
|
||||
export function buildPresentationHTML(input: PresentationInput): string {
|
||||
const r = resolveRecipe(input.theme)
|
||||
const total = input.slides.length
|
||||
const slidesHtml = input.slides.map((s, i) => renderSlide(s, r, i + 1, total)).join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>${esc(input.title)}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="${r.fontUrl}" rel="stylesheet">
|
||||
<style>
|
||||
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
|
||||
*,*::before,*::after{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
||||
.deck{width:100vw;height:100vh;position:relative;}
|
||||
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||||
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
|
||||
.slide>.content{position:relative;z-index:2;width:100%;max-width:1100px;padding:clamp(1.5rem,4vw,4rem);}
|
||||
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
|
||||
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
|
||||
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
|
||||
.nav-controls{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:100;display:flex;align-items:center;gap:12px;background:rgba(0,0,0,0.45);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.08);border-radius:9999px;padding:8px 16px;}
|
||||
.nav-dot{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,0.2);border:none;cursor:pointer;transition:all 0.3s;padding:0;flex-shrink:0;}
|
||||
.nav-dot.active{background:var(--color-accent-1);transform:scale(1.3);box-shadow:0 0 8px ${r.accent1}66;}
|
||||
.nav-btn{background:none;border:none;color:rgba(255,255,255,0.5);font-size:20px;cursor:pointer;padding:0 4px;}
|
||||
.nav-btn:hover{color:var(--color-accent-1);}
|
||||
.slide-counter{font-size:0.75rem;color:rgba(255,255,255,0.4);font-variant-numeric:tabular-nums;min-width:44px;text-align:center;}
|
||||
.bar{border-radius:6px 6px 0 0;transition:height 0.8s cubic-bezier(0.34,1.56,0.64,1);}
|
||||
.bar-fill{transition:width 0.8s cubic-bezier(0.34,1.56,0.64,1);}
|
||||
.line-path{stroke-dasharray:9999;stroke-dashoffset:9999;transition:stroke-dashoffset 1.2s ease;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="deck">
|
||||
${slidesHtml}
|
||||
</div>
|
||||
<div class="nav-controls" id="nav-controls">
|
||||
<button class="nav-btn" onclick="changeSlide(-1)">‹</button>
|
||||
<div id="nav-dots" style="display:flex;gap:8px;align-items:center;"></div>
|
||||
<button class="nav-btn" onclick="changeSlide(1)">›</button>
|
||||
<span class="slide-counter" id="slide-counter">1 / ${total}</span>
|
||||
</div>
|
||||
<script>
|
||||
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
|
||||
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
|
||||
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);}
|
||||
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
|
||||
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
|
||||
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
||||
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
||||
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);},300);}
|
||||
// Particles
|
||||
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
74
memento-note/lib/ai/tools/slides-palettes.ts
Normal file
74
memento-note/lib/ai/tools/slides-palettes.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Palette definitions and resolution helpers.
|
||||
* Shared between slides.tool.ts (server) and slides-renderer.tsx (client).
|
||||
*/
|
||||
import type { Palette, PresentationSpec } from '@/lib/types/presentation'
|
||||
|
||||
export type { Palette }
|
||||
|
||||
export const PALETTES: Record<string, Palette> = {
|
||||
// ── Dark / Keynote ───────────────────────────────────────────────────────────
|
||||
keynote: { primary: '#f1f5f9', secondary: '#7dd3fc', accent: '#6366f1', light: '#334155', bg: '#0f172a', isDark: true },
|
||||
galaxy: { primary: '#e2e8f0', secondary: '#a78bfa', accent: '#f472b6', light: '#1e1b4b', bg: '#0d1117', isDark: true },
|
||||
stage_dark: { primary: '#f9fafb', secondary: '#34d399', accent: '#fbbf24', light: '#1f2937', bg: '#111827', isDark: true },
|
||||
tech_night: { primary: '#e0e0e0', secondary: '#ffc300', accent: '#ffd60a', light: '#003566', bg: '#001d3d', isDark: true },
|
||||
luxury_mystery: { primary: '#f2e9e4', secondary: '#c9ada7', accent: '#9a8c98', light: '#4a4e69', bg: '#22223b', isDark: true },
|
||||
vibrant_orange_mint: { primary: '#f1f1f1', secondary: '#2ec4b6', accent: '#ff9f1c', light: '#0d2137', bg: '#1a1a2e', isDark: true },
|
||||
platinum_white_gold: { primary: '#0a0a0a', secondary: '#0070F3', accent: '#D4AF37', light: '#f5f5f5', bg: '#ffffff', isDark: false },
|
||||
// ── Light / Pro ──────────────────────────────────────────────────────────────
|
||||
modern_wellness: { primary: '#006d77', secondary: '#83c5be', accent: '#e29578', light: '#ffddd2', bg: '#edf6f9', isDark: false },
|
||||
business_authority: { primary: '#2b2d42', secondary: '#8d99ae', accent: '#ef233c', light: '#edf2f4', bg: '#edf2f4', isDark: false },
|
||||
nature_outdoors: { primary: '#606c38', secondary: '#283618', accent: '#dda15e', light: '#fefae0', bg: '#fefae0', isDark: false },
|
||||
vintage_academic: { primary: '#780000', secondary: '#669bbc', accent: '#c1121f', light: '#fdf0d5', bg: '#fdf0d5', isDark: false },
|
||||
soft_creative: { primary: '#7c6c8a', secondary: '#a89bbd', accent: '#d4a5c9', light: '#e8dff0', bg: '#f3eef8', isDark: false },
|
||||
bohemian: { primary: '#8a7e5e', secondary: '#a89e72', accent: '#c4a06a', light: '#e9dcc0', bg: '#f5eed8', isDark: false },
|
||||
vibrant_tech: { primary: '#023047', secondary: '#219ebc', accent: '#ffb703', light: '#8ecae6', bg: '#f8fbff', isDark: false },
|
||||
craft_artisan: { primary: '#5e3e28', secondary: '#8a6548', accent: '#a68a64', light: '#d4c4a8', bg: '#ede0d4', isDark: false },
|
||||
education_charts: { primary: '#264653', secondary: '#2a9d8f', accent: '#e76f51', light: '#e9c46a', bg: '#f4f1eb', isDark: false },
|
||||
forest_eco: { primary: '#344e41', secondary: '#588157', accent: '#a3b18a', light: '#dad7cd', bg: '#eae8e3', isDark: false },
|
||||
elegant_fashion: { primary: '#4a5759', secondary: '#8f9fa2', accent: '#b0c4b1', light: '#c9ada7', bg: '#f2e9e4', isDark: false },
|
||||
art_food: { primary: '#335c67', secondary: '#5e8a6f', accent: '#e09f3e', light: '#f3d97a', bg: '#fff8e1', isDark: false },
|
||||
pure_tech_blue: { primary: '#03045e', secondary: '#0077b6', accent: '#00b4d8', light: '#90e0ef', bg: '#caf0f8', isDark: false },
|
||||
coastal_coral: { primary: '#0081a7', secondary: '#00afb9', accent: '#f07167', light: '#fed9b7', bg: '#fdfcdc', isDark: false },
|
||||
architectural_mono: { primary: '#1C1C1C', secondary: '#D4A373', accent: '#A47148', light: '#F9F8F6', bg: '#F9F8F6', isDark: false },
|
||||
minimal_silk: { primary: '#212529', secondary: '#6c757d', accent: '#dee2e6', light: '#f8f9fa', bg: '#ffffff', isDark: false },
|
||||
}
|
||||
|
||||
export const PALETTE_ALIASES: Record<string, string> = {
|
||||
modern: 'vibrant_tech', corporate: 'business_authority', minimal: 'elegant_fashion',
|
||||
dark: 'keynote', midnight: 'galaxy', night: 'tech_night', forest: 'forest_eco',
|
||||
coral: 'coastal_coral', ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold',
|
||||
teal: 'education_charts', berry: 'art_food', cherry: 'vintage_academic',
|
||||
clair: 'pure_tech_blue', light: 'modern_wellness', warm: 'bohemian',
|
||||
premium: 'platinum_white_gold', clean: 'vibrant_tech', stage: 'stage_dark',
|
||||
architectural: 'architectural_mono', silk: 'minimal_silk',
|
||||
black: 'keynote', white: 'platinum_white_gold', nuit: 'galaxy', sombre: 'stage_dark',
|
||||
}
|
||||
|
||||
export const THEME_NAMES: Record<string, string> = {
|
||||
modern_wellness: 'Modern & Wellness', business_authority: 'Business & Authority',
|
||||
nature_outdoors: 'Nature & Outdoors', vintage_academic: 'Vintage & Academic',
|
||||
soft_creative: 'Soft & Creative', bohemian: 'Bohemian',
|
||||
vibrant_tech: 'Vibrant & Tech', craft_artisan: 'Craft & Artisan',
|
||||
tech_night: 'Tech & Night', education_charts: 'Education & Charts',
|
||||
forest_eco: 'Forest & Eco', elegant_fashion: 'Elegant & Fashion',
|
||||
art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery',
|
||||
pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral',
|
||||
vibrant_orange_mint: 'Vibrant Orange Mint', platinum_white_gold: 'Platinum White Gold',
|
||||
architectural_mono: 'Architectural Mono', minimal_silk: 'Minimal Silk',
|
||||
}
|
||||
|
||||
export function resolvePalette(spec: Pick<PresentationSpec, 'theme'>): { palette: Palette; key: string } {
|
||||
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
|
||||
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'keynote')
|
||||
return { palette: PALETTES[key]!, key }
|
||||
}
|
||||
|
||||
export function resolveRadius(style?: string): string {
|
||||
const s = (style || '').toLowerCase()
|
||||
if (s === 'sharp' || s === 'professional') return '4px'
|
||||
if (s === 'brutalist') return '0px'
|
||||
if (s === 'creative' || s === 'rounded') return '18px'
|
||||
if (s === 'pill') return '28px'
|
||||
return '12px'
|
||||
}
|
||||
@@ -4,764 +4,99 @@ import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildPresentationHTML } from './slides-html-builder'
|
||||
|
||||
interface SlideSpec {
|
||||
title: string
|
||||
subtitle?: string
|
||||
content: string[]
|
||||
layout?: 'title' | 'content' | 'section' | 'two-column' | 'cards' | 'stats' | 'quote' | 'toc' | 'summary' | 'image'
|
||||
imageUrl?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface PresentationSpec {
|
||||
title: string
|
||||
slides: SlideSpec[]
|
||||
theme?: string
|
||||
style?: string
|
||||
author?: string
|
||||
}
|
||||
|
||||
interface Palette {
|
||||
primary: string
|
||||
secondary: string
|
||||
accent: string
|
||||
light: string
|
||||
bg: string
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
const PALETTES: Record<string, Palette> = {
|
||||
modern_wellness: { primary: '#006d77', secondary: '#83c5be', accent: '#e29578', light: '#ffddd2', bg: '#edf6f9', isDark: false },
|
||||
business_authority: { primary: '#2b2d42', secondary: '#8d99ae', accent: '#ef233c', light: '#edf2f4', bg: '#edf2f4', isDark: false },
|
||||
nature_outdoors: { primary: '#606c38', secondary: '#283618', accent: '#dda15e', light: '#fefae0', bg: '#fefae0', isDark: false },
|
||||
vintage_academic: { primary: '#780000', secondary: '#669bbc', accent: '#c1121f', light: '#fdf0d5', bg: '#fdf0d5', isDark: false },
|
||||
soft_creative: { primary: '#7c6c8a', secondary: '#a89bbd', accent: '#d4a5c9', light: '#e8dff0', bg: '#f3eef8', isDark: false },
|
||||
bohemian: { primary: '#8a7e5e', secondary: '#a89e72', accent: '#c4a06a', light: '#e9dcc0', bg: '#f5eed8', isDark: false },
|
||||
vibrant_tech: { primary: '#023047', secondary: '#219ebc', accent: '#ffb703', light: '#8ecae6', bg: '#f8fbff', isDark: false },
|
||||
craft_artisan: { primary: '#5e3e28', secondary: '#8a6548', accent: '#a68a64', light: '#d4c4a8', bg: '#ede0d4', isDark: false },
|
||||
tech_night: { primary: '#e0e0e0', secondary: '#ffc300', accent: '#ffd60a', light: '#003566', bg: '#001d3d', isDark: true },
|
||||
education_charts: { primary: '#264653', secondary: '#2a9d8f', accent: '#e76f51', light: '#e9c46a', bg: '#f4f1eb', isDark: false },
|
||||
forest_eco: { primary: '#344e41', secondary: '#588157', accent: '#a3b18a', light: '#dad7cd', bg: '#eae8e3', isDark: false },
|
||||
elegant_fashion: { primary: '#4a5759', secondary: '#8f9fa2', accent: '#b0c4b1', light: '#c9ada7', bg: '#f2e9e4', isDark: false },
|
||||
art_food: { primary: '#335c67', secondary: '#5e8a6f', accent: '#e09f3e', light: '#f3d97a', bg: '#fff8e1', isDark: false },
|
||||
luxury_mystery: { primary: '#22223b', secondary: '#4a4e69', accent: '#9a8c98', light: '#c9ada7', bg: '#f2e9e4', isDark: false },
|
||||
pure_tech_blue: { primary: '#03045e', secondary: '#0077b6', accent: '#00b4d8', light: '#90e0ef', bg: '#caf0f8', isDark: false },
|
||||
coastal_coral: { primary: '#0081a7', secondary: '#00afb9', accent: '#f07167', light: '#fed9b7', bg: '#fdfcdc', isDark: false },
|
||||
vibrant_orange_mint: { primary: '#1a1a2e', secondary: '#2ec4b6', accent: '#ff9f1c', light: '#cbf3f0', bg: '#ffffff', isDark: false },
|
||||
platinum_white_gold: { primary: '#0a0a0a', secondary: '#0070F3', accent: '#D4AF37', light: '#f5f5f5', bg: '#ffffff', isDark: false },
|
||||
architectural_mono: { primary: '#1C1C1C', secondary: '#D4A373', accent: '#ACB995', light: '#F9F8F6', bg: '#F9F8F6', isDark: false },
|
||||
minimal_silk: { primary: '#212529', secondary: '#6c757d', accent: '#dee2e6', light: '#f8f9fa', bg: '#ffffff', isDark: false },
|
||||
}
|
||||
|
||||
const PALETTE_ALIASES: Record<string, string> = {
|
||||
modern: 'vibrant_tech', corporate: 'business_authority', minimal: 'elegant_fashion',
|
||||
dark: 'tech_night', midnight: 'luxury_mystery', forest: 'forest_eco', coral: 'coastal_coral',
|
||||
ocean: 'pure_tech_blue', charcoal: 'platinum_white_gold', teal: 'education_charts',
|
||||
berry: 'art_food', cherry: 'vintage_academic', clair: 'pure_tech_blue', light: 'modern_wellness',
|
||||
warm: 'bohemian', premium: 'platinum_white_gold', clean: 'vibrant_tech',
|
||||
architectural: 'architectural_mono', silk: 'minimal_silk',
|
||||
}
|
||||
|
||||
const THEME_NAMES: Record<string, string> = {
|
||||
modern_wellness: 'Modern & Wellness', business_authority: 'Business & Authority',
|
||||
nature_outdoors: 'Nature & Outdoors', vintage_academic: 'Vintage & Academic',
|
||||
soft_creative: 'Soft & Creative', bohemian: 'Bohemian',
|
||||
vibrant_tech: 'Vibrant & Tech', craft_artisan: 'Craft & Artisan',
|
||||
tech_night: 'Tech & Night', education_charts: 'Education & Charts',
|
||||
forest_eco: 'Forest & Eco', elegant_fashion: 'Elegant & Fashion',
|
||||
art_food: 'Art & Food', luxury_mystery: 'Luxury & Mystery',
|
||||
pure_tech_blue: 'Pure Tech Blue', coastal_coral: 'Coastal Coral',
|
||||
vibrant_orange_mint: 'Vibrant Orange Mint', platinum_white_gold: 'Platinum White Gold',
|
||||
architectural_mono: 'Architectural Mono', minimal_silk: 'Minimal Silk',
|
||||
}
|
||||
|
||||
function resolvePalette(spec: PresentationSpec): { palette: Palette; key: string } {
|
||||
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
|
||||
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'vibrant_tech')
|
||||
return { palette: PALETTES[key]!, key }
|
||||
}
|
||||
|
||||
function resolveRadius(style?: string): string {
|
||||
switch ((style || '').toLowerCase()) {
|
||||
case 'sharp': return '2px'
|
||||
case 'rounded': return '16px'
|
||||
case 'pill': return '24px'
|
||||
default: return '10px'
|
||||
}
|
||||
}
|
||||
|
||||
function esc(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function safeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
||||
.replace(/\son\w+\s*=\s*["'][^"']*["']/gi, '')
|
||||
.replace(/\son\w+\s*=\s*[^\s>]*/gi, '')
|
||||
.replace(/javascript\s*:/gi, '')
|
||||
}
|
||||
|
||||
function buildThemeCSS(p: Palette, radius: string, key: string): string {
|
||||
const text = p.isDark ? '#f0f0f0' : '#1a1a1a'
|
||||
const muted = p.isDark ? '#999' : '#555'
|
||||
const heading = p.isDark ? '#ffffff' : p.primary
|
||||
const bgText = p.isDark ? '#e0e0e0' : '#ffffff'
|
||||
const shadowAlpha = p.isDark ? '0.35' : '0.08'
|
||||
const shadowAlphaSm = p.isDark ? '0.25' : '0.05'
|
||||
|
||||
return `:root {
|
||||
--p-primary: ${p.primary};
|
||||
--p-secondary: ${p.secondary};
|
||||
--p-accent: ${p.accent};
|
||||
--p-light: ${p.light};
|
||||
--p-bg: ${p.bg};
|
||||
--p-text: ${text};
|
||||
--p-muted: ${muted};
|
||||
--p-heading: ${heading};
|
||||
--p-on-primary: ${bgText};
|
||||
--p-radius: ${radius};
|
||||
--p-shadow: 0 8px 32px rgba(0,0,0,${shadowAlpha});
|
||||
--p-shadow-sm: 0 2px 12px rgba(0,0,0,${shadowAlphaSm});
|
||||
--p-gradient: linear-gradient(135deg, ${p.primary} 0%, ${p.secondary} 100%);
|
||||
--p-border: ${p.isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
--p-border-accent: ${p.isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'};
|
||||
|
||||
--r-background-color: ${p.bg};
|
||||
--r-main-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--r-heading-font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--r-main-font-size: 26px;
|
||||
--r-heading-font-weight: 700;
|
||||
--r-heading-color: ${heading};
|
||||
--r-heading-line-height: 1.15;
|
||||
--r-heading-letter-spacing: -0.03em;
|
||||
--r-heading-text-transform: none;
|
||||
--r-heading-text-shadow: none;
|
||||
--r-heading1-size: 3.2em;
|
||||
--r-heading2-size: 2em;
|
||||
--r-heading3-size: 1.4em;
|
||||
--r-heading4-size: 1em;
|
||||
--r-main-color: ${text};
|
||||
--r-block-margin: 16px;
|
||||
--r-link-color: ${p.accent};
|
||||
--r-link-color-hover: ${p.secondary};
|
||||
--r-selection-background-color: ${p.accent};
|
||||
--r-selection-color: ${bgText};
|
||||
}
|
||||
|
||||
${key === 'architectural_mono' ? `
|
||||
.reveal-viewport {
|
||||
background-color: #F9F8F6 !important;
|
||||
background-image:
|
||||
linear-gradient(rgba(28, 28, 28, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(28, 28, 28, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(rgba(28, 28, 28, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(28, 28, 28, 0.04) 1px, transparent 1px) !important;
|
||||
background-size: 100px 100px, 100px 100px, 20px 20px, 20px 20px !important;
|
||||
}
|
||||
.reveal {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
}
|
||||
.reveal h1, .reveal h2, .reveal h3 {
|
||||
font-family: 'JetBrains Mono', monospace !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: -0.02em !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
.reveal h1 { border-left: 12px solid #D4A373; padding-left: 40px; }
|
||||
.reveal section { text-align: left; padding: 60px; }
|
||||
.reveal p, .reveal li { font-weight: 300; font-family: 'JetBrains Mono', monospace !important; }
|
||||
` : ''}`
|
||||
}
|
||||
|
||||
function buildLayoutCSS(): string {
|
||||
return `
|
||||
.reveal-viewport {
|
||||
background: var(--p-bg);
|
||||
background-image: linear-gradient(var(--p-border) 1px, transparent 1px), linear-gradient(90deg, var(--p-border) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
font-family: var(--r-main-font);
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--p-text);
|
||||
}
|
||||
|
||||
.reveal h1, .reveal h2, .reveal h3, .reveal h4 {
|
||||
font-family: var(--r-heading-font);
|
||||
font-weight: 700;
|
||||
text-transform: none;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.reveal h1 { font-size: var(--r-heading1-size); }
|
||||
.reveal h2 { font-size: var(--r-heading2-size); margin-bottom: 0.1em; }
|
||||
.reveal h3 { font-size: var(--r-heading3-size); }
|
||||
|
||||
.reveal section { padding: 40px 60px; text-align: left; }
|
||||
|
||||
.reveal a { color: var(--p-accent); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
|
||||
.reveal a:hover { border-bottom-color: var(--p-accent); }
|
||||
|
||||
.reveal .accent-bar {
|
||||
width: 48px; height: 3px; background: var(--p-accent);
|
||||
border-radius: 2px; margin-bottom: 1.4rem; flex-shrink: 0;
|
||||
}
|
||||
.reveal .accent-bar--center {
|
||||
margin-left: auto; margin-right: auto;
|
||||
}
|
||||
.reveal .accent-bar--wide {
|
||||
width: 80px; height: 4px;
|
||||
}
|
||||
|
||||
/* ======= DECORATIVE FRAME ======= */
|
||||
.reveal .frame-top,
|
||||
.reveal .frame-bottom,
|
||||
.reveal .frame-left,
|
||||
.reveal .frame-right {
|
||||
position: fixed; z-index: 10; background: var(--p-accent); pointer-events: none;
|
||||
}
|
||||
.reveal .frame-top { top: 0; left: 0; right: 0; height: 4px; }
|
||||
.reveal .frame-bottom { bottom: 0; left: 0; right: 0; height: 4px; }
|
||||
.reveal .frame-left { top: 0; bottom: 0; left: 0; width: 4px; }
|
||||
.reveal .frame-right { top: 0; bottom: 0; right: 0; width: 4px; }
|
||||
|
||||
/* ======= TITLE ======= */
|
||||
.reveal .s-title {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 100%; text-align: center; padding: 0 80px;
|
||||
}
|
||||
.reveal .s-title::before {
|
||||
content: ''; position: absolute; inset: 0; z-index: -1;
|
||||
background: var(--p-gradient); opacity: 0.06;
|
||||
}
|
||||
.reveal .s-title h1 {
|
||||
color: var(--p-heading); margin: 0; line-height: 1.1;
|
||||
}
|
||||
.reveal .s-title .subtitle {
|
||||
color: var(--p-muted); font-size: 16pt; margin-top: 1.2rem;
|
||||
font-weight: 300; letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ======= SECTION DIVIDER ======= */
|
||||
.reveal .s-section {
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
height: 100%; text-align: center;
|
||||
}
|
||||
.reveal .s-section::before {
|
||||
content: ''; position: absolute; inset: 0; z-index: -1;
|
||||
background: var(--p-light);
|
||||
}
|
||||
.reveal .s-section .section-num {
|
||||
color: var(--p-accent); font-size: 120pt; font-weight: 800;
|
||||
opacity: 0.12; line-height: 1; margin-bottom: -0.3em;
|
||||
}
|
||||
.reveal .s-section h2 {
|
||||
color: var(--p-heading);
|
||||
}
|
||||
.reveal .s-section .subtitle {
|
||||
color: var(--p-muted); font-size: 14pt; margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ======= TOC ======= */
|
||||
.reveal .s-toc h2 { color: var(--p-heading); }
|
||||
.reveal .s-toc .toc-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.reveal .s-toc .toc-item {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 10px 16px; border-radius: var(--p-radius);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.reveal .s-toc .toc-item:nth-child(odd) { background: var(--p-border); }
|
||||
.reveal .s-toc .toc-num {
|
||||
color: var(--p-accent); font-size: 24pt; font-weight: 800;
|
||||
min-width: 50px; text-align: right; line-height: 1;
|
||||
}
|
||||
.reveal .s-toc .toc-label {
|
||||
color: var(--p-text); font-size: 14pt; padding-left: 12px;
|
||||
border-left: 3px solid var(--p-secondary);
|
||||
}
|
||||
|
||||
/* ======= CONTENT ======= */
|
||||
.reveal .s-content h2 { color: var(--p-heading); }
|
||||
.reveal .s-content ul { list-style: none; padding: 0; margin: 0; }
|
||||
.reveal .s-content li {
|
||||
color: var(--p-text); font-size: 14pt; padding: 8px 0;
|
||||
display: flex; align-items: flex-start; gap: 14px; line-height: 1.5;
|
||||
}
|
||||
.reveal .s-content li::before {
|
||||
content: ''; display: block; width: 8px; height: 8px; min-width: 8px;
|
||||
background: var(--p-accent); border-radius: 50%; margin-top: 0.5em;
|
||||
}
|
||||
|
||||
/* ======= TWO COLUMN ======= */
|
||||
.reveal .s-twocol h2 { color: var(--p-heading); }
|
||||
.reveal .s-twocol .cols {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
|
||||
}
|
||||
.reveal .s-twocol .col {
|
||||
background: var(--p-border); border-radius: var(--p-radius);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.reveal .s-twocol .col--accent {
|
||||
border-left: 3px solid var(--p-accent);
|
||||
}
|
||||
.reveal .s-twocol .col p {
|
||||
color: var(--p-text); font-size: 13pt; margin: 8px 0; line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ======= CARDS ======= */
|
||||
.reveal .s-cards h2 { color: var(--p-heading); }
|
||||
.reveal .s-cards .card-grid { display: grid; gap: 14px; }
|
||||
.reveal .s-cards .card-grid.g2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.reveal .s-cards .card-grid.g3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.reveal .s-cards .card {
|
||||
border-radius: var(--p-radius); padding: 22px 24px;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
border: 1px solid var(--p-border-accent);
|
||||
background: var(--p-border);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.reveal .s-cards .card:nth-child(odd) {
|
||||
background: var(--p-primary); border-color: transparent;
|
||||
box-shadow: 0 10px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.reveal .s-cards .card:nth-child(odd) .card-num { color: rgba(255,255,255,0.4); }
|
||||
.reveal .s-cards .card:nth-child(odd) .card-text { color: #ffffff; font-weight: 300; }
|
||||
.reveal .s-cards .card:nth-child(even) {
|
||||
background: #ffffff; border: 1px solid var(--p-border-accent);
|
||||
}
|
||||
.reveal .s-cards .card:nth-child(even) .card-num { color: var(--p-accent); opacity: 0.6; }
|
||||
.reveal .s-cards .card:nth-child(even) .card-text { color: var(--p-text); }
|
||||
.reveal .s-cards .card-num {
|
||||
font-size: 18pt; font-weight: 800; line-height: 1;
|
||||
}
|
||||
.reveal .s-cards .card-text {
|
||||
font-size: 12pt; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ======= STATS ======= */
|
||||
.reveal .s-stats h2 { color: var(--p-heading); }
|
||||
.reveal .s-stats .stat-grid {
|
||||
display: grid; gap: 28px; margin-top: 1.2rem;
|
||||
}
|
||||
.reveal .s-stats .stat {
|
||||
text-align: center; padding-top: 16px;
|
||||
border-top: 4px solid var(--p-accent);
|
||||
}
|
||||
.reveal .s-stats .stat-value {
|
||||
color: var(--p-heading); font-size: 44pt; font-weight: 800; line-height: 1;
|
||||
}
|
||||
.reveal .s-stats .stat-label {
|
||||
color: var(--p-muted); font-size: 12pt; margin-top: 8px;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* ======= QUOTE ======= */
|
||||
.reveal .s-quote {
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
height: 100%; text-align: left; padding: 0 80px;
|
||||
}
|
||||
.reveal .s-quote::before {
|
||||
content: ''; position: absolute; inset: 0; z-index: -1;
|
||||
background: var(--p-gradient); opacity: 0.08;
|
||||
}
|
||||
.reveal .s-quote .q-mark {
|
||||
color: var(--p-accent); font-size: 100pt; font-weight: 700;
|
||||
line-height: 0.4; font-family: 'Playfair Display', Georgia, serif;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.reveal .s-quote blockquote {
|
||||
color: var(--p-heading); font-size: 22pt; font-style: italic;
|
||||
line-height: 1.5; margin: 16px 0 24px;
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
border: none; box-shadow: none; padding: 0; background: none;
|
||||
width: 100%; text-align: left;
|
||||
}
|
||||
.reveal .s-quote cite {
|
||||
color: var(--p-accent); font-size: 12pt; font-style: normal;
|
||||
font-family: var(--r-main-font);
|
||||
}
|
||||
|
||||
/* ======= SUMMARY ======= */
|
||||
.reveal .s-summary h2 { color: var(--p-heading); }
|
||||
.reveal .s-summary::before {
|
||||
content: ''; position: absolute; inset: 0; z-index: -1;
|
||||
background: var(--p-light);
|
||||
}
|
||||
.reveal .s-summary .summary-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.reveal .s-summary .summary-item {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 10px 16px; border-radius: var(--p-radius);
|
||||
background: var(--p-border);
|
||||
}
|
||||
.reveal .s-summary .summary-dot {
|
||||
width: 10px; height: 10px; min-width: 10px;
|
||||
background: var(--p-accent); border-radius: 50%;
|
||||
}
|
||||
.reveal .s-summary .summary-text {
|
||||
color: var(--p-text); font-size: 14pt;
|
||||
}
|
||||
|
||||
/* ======= IMAGE ======= */
|
||||
.reveal .s-image h2 { color: var(--p-heading); }
|
||||
.reveal .s-image img {
|
||||
max-width: 85%; max-height: 55vh; border-radius: var(--p-radius);
|
||||
box-shadow: var(--p-shadow); display: block; margin: 1rem auto 0;
|
||||
}
|
||||
.reveal .s-image .caption {
|
||||
color: var(--p-muted); font-size: 11pt; text-align: center; margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ======= UI ======= */
|
||||
.reveal .controls button { color: var(--p-accent); }
|
||||
.reveal .progress span { background: var(--p-accent); }
|
||||
.reveal .slide-number { color: var(--p-muted); font-size: 10pt; opacity: 0.7; }
|
||||
|
||||
/* ======= PRINT ======= */
|
||||
@page { size: 1219px 686px; margin: 0; }
|
||||
@media print {
|
||||
.reveal section { padding: 20px; }
|
||||
.reveal .frame-top, .reveal .frame-bottom,
|
||||
.reveal .frame-left, .reveal .frame-right { display: none; }
|
||||
}`
|
||||
}
|
||||
|
||||
function renderSlide(slide: SlideSpec, index: number): string {
|
||||
const layout = slide.layout || (index === 0 ? 'title' : 'content')
|
||||
let html = ''
|
||||
|
||||
switch (layout) {
|
||||
case 'title':
|
||||
html = `<section class="s-title">
|
||||
<div class="accent-bar accent-bar--wide accent-bar--center"></div>
|
||||
<h1>${safeHtml(slide.title)}</h1>
|
||||
${slide.subtitle ? `<p class="subtitle">${safeHtml(slide.subtitle)}</p>` : ''}
|
||||
<div class="accent-bar accent-bar--wide accent-bar--center" style="margin-top:1.5rem;"></div>
|
||||
</section>`
|
||||
break
|
||||
|
||||
case 'toc':
|
||||
html = `<section class="s-toc">
|
||||
<h2>${safeHtml(slide.title || 'Sommaire')}</h2>
|
||||
<div class="accent-bar"></div>
|
||||
<div class="toc-list">
|
||||
${slide.content.map((item, i) => `<div class="toc-item">
|
||||
<span class="toc-num">${String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="toc-label">${safeHtml(item)}</span>
|
||||
</div>`).join('\n ')}
|
||||
</div>
|
||||
</section>`
|
||||
break
|
||||
|
||||
case 'section':
|
||||
html = `<section class="s-section">
|
||||
<span class="section-num">${safeHtml(slide.content[0] || String(index).padStart(2, '0'))}</span>
|
||||
<h2>${safeHtml(slide.title)}</h2>
|
||||
${slide.subtitle ? `<p class="subtitle">${safeHtml(slide.subtitle)}</p>` : ''}
|
||||
<div class="accent-bar accent-bar--center" style="margin-top:1rem;"></div>
|
||||
</section>`
|
||||
break
|
||||
|
||||
case 'content':
|
||||
html = `<section class="s-content">
|
||||
<h2>${safeHtml(slide.title)}</h2>
|
||||
<div class="accent-bar"></div>
|
||||
<ul>
|
||||
${slide.content.map(item => `<li><span>${safeHtml(item)}</span></li>`).join('\n ')}
|
||||
</ul>
|
||||
</section>`
|
||||
break
|
||||
|
||||
case 'two-column': {
|
||||
const mid = Math.ceil(slide.content.length / 2)
|
||||
const left = slide.content.slice(0, mid)
|
||||
const right = slide.content.slice(mid)
|
||||
html = `<section class="s-twocol">
|
||||
<h2>${safeHtml(slide.title)}</h2>
|
||||
<div class="accent-bar"></div>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
${left.map(item => `<p>${safeHtml(item)}</p>`).join('\n ')}
|
||||
</div>
|
||||
<div class="col col--accent">
|
||||
${right.map(item => `<p>${safeHtml(item)}</p>`).join('\n ')}
|
||||
</div>
|
||||
</div>
|
||||
</section>`
|
||||
break
|
||||
}
|
||||
|
||||
case 'cards': {
|
||||
const items = slide.content.slice(0, 6)
|
||||
const gClass = items.length <= 3 ? `g${items.length}` : 'g2'
|
||||
html = `<section class="s-cards">
|
||||
<h2>${safeHtml(slide.title)}</h2>
|
||||
<div class="accent-bar"></div>
|
||||
<div class="card-grid ${gClass}">
|
||||
${items.map((item, i) => `<div class="card">
|
||||
<span class="card-num">${String(i + 1).padStart(2, '0')}</span>
|
||||
<p class="card-text">${safeHtml(item)}</p>
|
||||
</div>`).join('\n ')}
|
||||
</div>
|
||||
</section>`
|
||||
break
|
||||
}
|
||||
|
||||
case 'stats':
|
||||
html = `<section class="s-stats">
|
||||
<h2>${safeHtml(slide.title)}</h2>
|
||||
<div class="accent-bar"></div>
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(${slide.content.slice(0, 4).length}, 1fr);">
|
||||
${slide.content.slice(0, 4).map(item => {
|
||||
const parts = item.split(/[-\u2013\u2014:]/)
|
||||
const stat = parts[0]?.trim() || item
|
||||
const label = parts.slice(1).join(':').trim()
|
||||
return `<div class="stat">
|
||||
<div class="stat-value">${safeHtml(stat)}</div>
|
||||
${label ? `<div class="stat-label">${safeHtml(label)}</div>` : ''}
|
||||
</div>`
|
||||
}).join('\n ')}
|
||||
</div>
|
||||
</section>`
|
||||
break
|
||||
|
||||
case 'quote':
|
||||
html = `<section class="s-quote">
|
||||
<div class="q-mark">\u201C</div>
|
||||
<blockquote>${safeHtml(slide.title)}</blockquote>
|
||||
${slide.subtitle ? `<cite>\u2014 ${safeHtml(slide.subtitle)}</cite>` : ''}
|
||||
</section>`
|
||||
break
|
||||
|
||||
case 'summary':
|
||||
html = `<section class="s-summary">
|
||||
<h2>${safeHtml(slide.title || 'En r\u00e9sum\u00e9')}</h2>
|
||||
<div class="accent-bar"></div>
|
||||
<div class="summary-list">
|
||||
${slide.content.slice(0, 5).map(item => `<div class="summary-item">
|
||||
<div class="summary-dot"></div>
|
||||
<span class="summary-text">${safeHtml(item)}</span>
|
||||
</div>`).join('\n ')}
|
||||
</div>
|
||||
</section>`
|
||||
break
|
||||
|
||||
case 'image':
|
||||
html = `<section class="s-image">
|
||||
<h2>${safeHtml(slide.title)}</h2>
|
||||
<div class="accent-bar"></div>
|
||||
${slide.imageUrl ? `<img src="${esc(slide.imageUrl)}" alt="${esc(slide.title)}">` : ''}
|
||||
${slide.content[0] ? `<p class="caption">${safeHtml(slide.content[0])}</p>` : ''}
|
||||
</section>`
|
||||
break
|
||||
|
||||
default:
|
||||
html = `<section class="s-content">
|
||||
<h2>${safeHtml(slide.title)}</h2>
|
||||
<ul>
|
||||
${slide.content.map(item => `<li><span>${safeHtml(item)}</span></li>`).join('\n ')}
|
||||
</ul>
|
||||
</section>`
|
||||
}
|
||||
|
||||
if (slide.notes) {
|
||||
html = html.replace('</section>', `<aside class="notes">${esc(slide.notes)}</aside>\n</section>`)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
function buildRevealHtml(spec: PresentationSpec): string {
|
||||
const { palette, key } = resolvePalette(spec)
|
||||
const baseTheme = palette.isDark ? 'moon' : 'white'
|
||||
const radius = resolveRadius(spec.style)
|
||||
const slidesHtml = spec.slides.map((s, i) => renderSlide(s, i)).join('\n')
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${esc(spec.title)}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/${baseTheme}.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
${buildThemeCSS(palette, radius, key)}
|
||||
|
||||
${buildLayoutCSS()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="frame-top"></div>
|
||||
<div class="frame-bottom"></div>
|
||||
<div class="frame-left"></div>
|
||||
<div class="frame-right"></div>
|
||||
<div class="slides">
|
||||
${slidesHtml}
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/notes/notes.js"></script>
|
||||
<script>
|
||||
Reveal.initialize({
|
||||
hash: true,
|
||||
slideNumber: 'c/t',
|
||||
showSlideNumber: 'all',
|
||||
transition: 'slide',
|
||||
transitionSpeed: 'default',
|
||||
backgroundTransition: 'fade',
|
||||
center: true,
|
||||
margin: 0.06,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
plugins: [ RevealNotes ],
|
||||
keyboard: true,
|
||||
overview: true,
|
||||
touch: true,
|
||||
loop: false,
|
||||
controls: true,
|
||||
controlsLayout: 'bottom-right',
|
||||
controlsBackArrows: 'visible',
|
||||
progress: true,
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function parseSlidesFromText(text: string): PresentationSpec {
|
||||
const lines = text.split('\n').filter(l => l.trim().length > 0)
|
||||
const title = lines[0]?.replace(/^#+\s*/, '').trim() || 'Presentation'
|
||||
const slides: SlideSpec[] = []
|
||||
let current: SlideSpec | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim()
|
||||
if (t.match(/^#{1,2}\s+/) || t.match(/^slide\s+\d+/i)) {
|
||||
if (current) slides.push(current)
|
||||
current = { title: t.replace(/^#{1,2}\s+/, '').replace(/^slide\s+\d+\s*[:-]?\s*/i, ''), content: [] }
|
||||
} else if (current && (t.match(/^[-*]\s+/) || t.match(/^\d+\.\s+/))) {
|
||||
current.content.push(t.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, ''))
|
||||
}
|
||||
}
|
||||
if (current) slides.push(current)
|
||||
if (slides.length === 0) slides.push({ title, content: lines.slice(1, 8).map(l => l.replace(/^[-*]\s+/, '').replace(/^\d+\.\s+/, '')) })
|
||||
|
||||
return { title, slides }
|
||||
}
|
||||
const slideSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
|
||||
z.object({ type: z.literal('chart'), title: z.string(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']), data: z.array(z.object({ label: z.string(), value: z.number() })), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
|
||||
z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
|
||||
z.object({ type: z.literal('cards'), title: z.string(), cards: z.array(z.object({ title: z.string(), description: z.string() })) }),
|
||||
z.object({ type: z.literal('timeline'), title: z.string(), events: z.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) }),
|
||||
z.object({ type: z.literal('quote'), quote: z.string(), author: z.string().optional(), context: z.string().optional() }),
|
||||
z.object({ type: z.literal('comparison'), title: z.string(), left: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }), right: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) }),
|
||||
z.object({ type: z.literal('equation'), title: z.string(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })), explanation: z.string().optional() }),
|
||||
z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
|
||||
z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.string()) }),
|
||||
])
|
||||
|
||||
toolRegistry.register({
|
||||
name: 'generate_slides',
|
||||
description: 'Generate a beautiful HTML presentation with Reveal.js and save it for viewing.',
|
||||
description: 'Renders a structured presentation from JSON data into a full animated HTML file and saves it.',
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Generate a beautiful HTML presentation using Reveal.js and save it.
|
||||
description: `Create a presentation from structured slide data. Each slide has a type and corresponding content.
|
||||
|
||||
Provide a JSON specification:
|
||||
{
|
||||
"title": "Presentation Title",
|
||||
"theme": "vibrant_tech",
|
||||
"slides": [
|
||||
{ "title": "Title", "subtitle": "Subtitle", "content": [], "layout": "title" },
|
||||
{ "title": "Sommaire", "content": ["Section 1", "Section 2"], "layout": "toc" },
|
||||
{ "title": "Key Points", "content": ["Point 1", "Point 2"], "layout": "content" },
|
||||
{ "title": "Features", "content": ["Feature A: desc", "Feature B: desc"], "layout": "cards" },
|
||||
{ "title": "Metrics", "content": ["99% - Uptime", "50K - Users"], "layout": "stats" },
|
||||
{ "title": "Introduction", "content": ["01"], "subtitle": "Topic", "layout": "section" },
|
||||
{ "title": "A great quote.", "subtitle": "- Author", "layout": "quote" },
|
||||
{ "title": "Summary", "content": ["Point 1", "Point 2"], "layout": "summary" }
|
||||
]
|
||||
}
|
||||
|
||||
THEMES: modern_wellness, business_authority, nature_outdoors, vintage_academic, soft_creative, bohemian, vibrant_tech, craft_artisan, tech_night, education_charts, forest_eco, elegant_fashion, art_food, luxury_mystery, pure_tech_blue, coastal_coral, vibrant_orange_mint, platinum_white_gold
|
||||
|
||||
LAYOUTS: title, toc, content, section, two-column, cards, stats, quote, summary, image
|
||||
Available slide types:
|
||||
- "title": title, subtitle (opening slide with particles)
|
||||
- "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
|
||||
- "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
|
||||
- "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
|
||||
- "table": title, headers[], rows[][] (data table)
|
||||
- "cards": title, cards[{title, description}] (3-6 info cards)
|
||||
- "timeline": title, events[{date, title, description}] (chronological events)
|
||||
- "quote": quote, author, context (citation with analysis)
|
||||
- "comparison": title, left{title, points[], score}, right{...} (A vs B)
|
||||
- "equation": title, equations[{latex, label}], explanation (math formulas)
|
||||
- "image": title, url, caption (image slide)
|
||||
- "summary": title, items[] (conclusion with checkmarks)
|
||||
|
||||
RULES:
|
||||
- First slide MUST be "title"
|
||||
- Second slide should be "toc"
|
||||
- Use "section" for dividers (content[0]=section number like "01")
|
||||
- Use "cards" for feature lists (3-6 items)
|
||||
- Use "stats" for numbers (format: "NUMBER - LABEL")
|
||||
- Use "quote" for quotes (title=quote, subtitle=attribution)
|
||||
- Use "summary" for closing summary
|
||||
- Use "two-column" for comparisons
|
||||
- 5-12 slides, vary layouts, no repeats consecutively`,
|
||||
|
||||
- 6-12 slides per presentation
|
||||
- First slide MUST be type "title"
|
||||
- Last slide MUST be type "summary"
|
||||
- Include at least 1 "chart" or "stats" slide
|
||||
- Use VARIED types — never 2 identical types in a row
|
||||
- All text content must come from the source notes (never invent data)
|
||||
- Each bullet/card must be a real sentence (15+ words, not generic)`,
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Title for the presentation'),
|
||||
slides: z.string().describe('JSON presentation specification'),
|
||||
title: z.string().describe('Short presentation title (6 words max)'),
|
||||
theme: z.string().optional().describe('Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.'),
|
||||
slides: z.array(slideSchema).describe('Array of slide objects, each with a "type" field'),
|
||||
}),
|
||||
execute: async ({ title, slides }) => {
|
||||
|
||||
execute: async ({ title, theme, slides }) => {
|
||||
try {
|
||||
console.log('[Slides Tool] INPUT title:', title)
|
||||
console.log('[Slides Tool] INPUT slides (first 500 chars):', slides?.substring(0, 500))
|
||||
console.log('[Slides Tool] Building presentation:', title, '| Slides:', slides.length, '| Theme:', theme)
|
||||
|
||||
let spec: PresentationSpec
|
||||
try {
|
||||
const parsed = JSON.parse(slides)
|
||||
console.log('[Slides Tool] JSON parsed OK. slides count:', parsed.slides?.length, 'theme:', parsed.theme, 'title:', parsed.title)
|
||||
if (parsed.slides && Array.isArray(parsed.slides) && parsed.slides.length > 0) {
|
||||
spec = {
|
||||
title: parsed.title || title || 'Presentation',
|
||||
theme: parsed.theme || 'vibrant_tech',
|
||||
style: parsed.style,
|
||||
slides: parsed.slides.map((s: any) => ({
|
||||
title: String(s.title || '').substring(0, 200),
|
||||
subtitle: s.subtitle ? String(s.subtitle).substring(0, 300) : undefined,
|
||||
content: Array.isArray(s.content) ? s.content.map((c: any) => String(c).substring(0, 500)).slice(0, 12) : [],
|
||||
layout: ['title', 'content', 'section', 'two-column', 'cards', 'stats', 'quote', 'toc', 'summary', 'image'].includes(s.layout) ? s.layout : undefined,
|
||||
imageUrl: s.imageUrl ? String(s.imageUrl).substring(0, 500) : undefined,
|
||||
notes: s.notes ? String(s.notes).substring(0, 1000) : undefined,
|
||||
})),
|
||||
}
|
||||
} else {
|
||||
console.log('[Slides Tool] No slides array in JSON, falling back to text parse')
|
||||
spec = parseSlidesFromText(slides)
|
||||
}
|
||||
} catch (parseErr) {
|
||||
console.log('[Slides Tool] JSON parse failed, falling back to text parse:', parseErr)
|
||||
spec = parseSlidesFromText(slides)
|
||||
}
|
||||
|
||||
console.log('[Slides Tool] Spec:', JSON.stringify({ title: spec.title, theme: spec.theme, style: spec.style, slideCount: spec.slides.length, layouts: spec.slides.map(s => s.layout) }))
|
||||
|
||||
if (spec.slides.length === 0) {
|
||||
console.log('[Slides Tool] ERROR: No slides provided')
|
||||
return { success: false, error: 'No slides provided' }
|
||||
}
|
||||
|
||||
const html = buildRevealHtml(spec)
|
||||
console.log('[Slides Tool] HTML generated. Length:', html.length, '| Start:', html.substring(0, 120))
|
||||
const html = buildPresentationHTML({ title, theme, slides: slides as any })
|
||||
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: title || spec.title || 'Presentation',
|
||||
name: title || 'Présentation',
|
||||
data: JSON.stringify({
|
||||
type: 'slides',
|
||||
title: spec.title,
|
||||
theme: spec.theme,
|
||||
slideCount: spec.slides.length,
|
||||
title: title || 'Présentation',
|
||||
html,
|
||||
slideCount: slides.length,
|
||||
theme: theme || 'architectural-saas',
|
||||
spec: { title, theme, slides },
|
||||
}),
|
||||
userId: ctx.userId,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('[Slides Tool] Canvas created:', canvas.id, canvas.name)
|
||||
console.log('[Slides Tool] Canvas created:', canvas.id, '| Slides:', slides.length, '| Size:', Math.round(html.length / 1024), 'KB')
|
||||
|
||||
if (ctx.actionId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides generated: ${slides.length} slides, ${Math.round(html.length / 1024)}KB`,
|
||||
},
|
||||
}).catch(err => console.error('[Slides Tool] Failed to update action status:', err))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true, canvasId: canvas.id, canvasName: canvas.name,
|
||||
slideCount: spec.slides.length, theme: spec.theme,
|
||||
message: `Presentation created with ${spec.slides.length} slides. Open in browser to view.`,
|
||||
success: true,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name,
|
||||
slideCount: slides.length,
|
||||
message: `Presentation "${canvas.name}" created with ${slides.length} slides.`,
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[Slides Tool] FATAL:', e)
|
||||
|
||||
Reference in New Issue
Block a user