773 lines
29 KiB
TypeScript
773 lines
29 KiB
TypeScript
'use server'
|
|
|
|
import { tool } from 'ai'
|
|
import { z } from 'zod'
|
|
import { toolRegistry } from './registry'
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
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 }
|
|
}
|
|
|
|
toolRegistry.register({
|
|
name: 'generate_slides',
|
|
description: 'Generate a beautiful HTML presentation with Reveal.js and save it for viewing.',
|
|
isInternal: true,
|
|
buildTool: (ctx) =>
|
|
tool({
|
|
description: `Generate a beautiful HTML presentation using Reveal.js and save it.
|
|
|
|
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
|
|
|
|
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`,
|
|
|
|
inputSchema: z.object({
|
|
title: z.string().describe('Title for the presentation'),
|
|
slides: z.string().describe('JSON presentation specification'),
|
|
}),
|
|
execute: async ({ title, slides }) => {
|
|
try {
|
|
console.log('[Slides Tool] INPUT title:', title)
|
|
console.log('[Slides Tool] INPUT slides (first 500 chars):', slides?.substring(0, 500))
|
|
|
|
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 canvas = await prisma.canvas.create({
|
|
data: {
|
|
name: title || spec.title || 'Presentation',
|
|
data: JSON.stringify({
|
|
type: 'slides',
|
|
title: spec.title,
|
|
theme: spec.theme,
|
|
slideCount: spec.slides.length,
|
|
html,
|
|
}),
|
|
userId: ctx.userId,
|
|
},
|
|
})
|
|
|
|
console.log('[Slides Tool] Canvas created:', canvas.id, canvas.name)
|
|
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.`,
|
|
}
|
|
} catch (e: any) {
|
|
console.error('[Slides Tool] FATAL:', e)
|
|
return { success: false, error: `Failed: ${e.message}` }
|
|
}
|
|
},
|
|
}),
|
|
})
|