feat: add slides generation tool with multiple slide types
Some checks failed
CI / Lint, Test & Build (push) Failing after 17s
CI / Deploy production (on server) (push) Has been skipped

- 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:
Antigravity
2026-05-22 17:18:48 +00:00
parent 0f6b9509da
commit 5728452b4a
68 changed files with 6990 additions and 2584 deletions

View File

@@ -190,19 +190,110 @@ Résultats : notification in-app et par email selon configuration.
## 7. Brainstorm radial
Le brainstorm est un espace de pensée visuelle qui génère des idées par vagues successives à partir d'une idée graine, sur un canvas interactif. Il est accessible depuis la barre latérale (icône Wind) ou directement depuis n'importe quelle note via le bouton **⌁** dans la barre d'outils de l'éditeur.
Documentation technique : [brainstorm-documentation.md](../../memento-note/docs/brainstorm-documentation.md).
### Parcours type
---
1. Saisir une **idée graine** (ou partir dune note existante).
2. LIA génère **3 vagues** : Variations → Analogies → Disruptions (9 idées).
3. Sur le **canvas D3** : zoom, drag, approfondir, rejeter, convertir en note.
4. **Partager** la session (lien invité ; lhôte paie les tokens en mode host-pays).
5. **Exporter** en note structurée ou formats de présentation.
### Démarrer une session
### Collaboration
**Depuis le canvas vide :**
Socket.io : curseurs en direct, déplacement de nœuds, présence des participants (host / editor / viewer).
1. Saisir une idée, question ou sujet dans le champ de saisie en haut.
2. Appuyer sur **Entrée** ou le bouton **+**.
3. L'IA génère immédiatement une première vague de 3 idées.
**Prompts suggérés :** si vous manquez d'inspiration, des exemples de graines apparaissent sous le champ quand aucune session n'est active. Un clic sur l'un d'eux lance directement le brainstorm.
**Depuis une note :** cliquer sur le bouton ⌁ (Wind) dans la barre d'outils de l'éditeur. Le titre et le début du contenu de la note deviennent automatiquement la graine.
---
### Les vagues de pensée
L'IA explore trois angles distincts, chacun représenté par une couleur sur le canvas :
| Vague | Couleur | Angle exploré |
|-------|---------|---------------|
| 1 — Variations | Orange | Déclinaisons directes de la graine |
| 2 — Analogies | Bleu | Parallèles avec d'autres domaines |
| 3 — Disruptions | Violet | Remises en question radicales |
Chaque vague produit 3 idées. Au total : jusqu'à 9 idées par session, organisées radialement autour du nœud graine central.
---
### Naviguer sur le canvas
- **Zoom** : molette ou pinch sur mobile.
- **Déplacer** : cliquer-glisser sur le fond.
- **Sélectionner une idée** : cliquer sur un nœud — le panneau détail s'ouvre à droite (ou en bas sur mobile).
- **Ajouter une idée manuellement** : bouton **+ Ajouter une idée** en bas à gauche, ou double-clic sur le canvas.
**Vue mobile :** sur petit écran, un toggle **Canvas / Liste** apparaît sous le champ de saisie. La vue liste regroupe les idées par vague en cartes scrollables, plus lisibles sur téléphone.
---
### Panneau détail d'une idée
Un clic sur un nœud ouvre le panneau détail (slide depuis la droite sur desktop, bottom sheet sur mobile) qui affiche :
- Le numéro de vague et l'angle (Variation / Analogie / Disruption).
- Le titre et la description complète.
- Le **score d'originalité** (010) et l'auteur (IA ou humain).
- Le **lien avec la graine** : texte explicatif de la connexion logique.
- Les **notes source** qui ont inspiré l'idée (si la session est lancée depuis une note).
**Actions disponibles dans le panneau :**
| Action | Effet |
|--------|-------|
| ⭐ Étoile | Marque l'idée comme favorite (visible dans la vue liste) |
| **Creuser** | L'IA génère une vague supplémentaire à partir de cette idée |
| **Créer une note** | Convertit l'idée en note dans votre carnet (badge vert affiché) |
| **Pas pertinent** | Rejette l'idée (elle disparaît du canvas) |
---
### Exporter la session
Le bouton **Export** dans la barre d'actions ouvre une modale en deux étapes :
1. **Bilan de session** : l'IA génère automatiquement une synthèse de 4 à 6 phrases résumant les thèmes explorés et proposant une prochaine action concrète. Vous pouvez la regénérer si elle ne vous convient pas.
2. **Exporter en note** : crée une note structurée dans votre carnet par défaut avec toutes les idées actives et la synthèse.
> Fermer la modale sans cliquer sur "Exporter en note" n'exporte rien — vous pouvez consulter le bilan seul.
---
### Gérer ses sessions
**Historique des sessions :** la colonne de droite (icône History) liste toutes vos sessions sous forme de boutons lettrés (première lettre de la graine). Cliquer sur un bouton charge la session.
**Renommer une session :** survoler le bouton de session actif fait apparaître une icône crayon. Cliquer dessus ouvre une boîte de dialogue pour modifier le titre (l'idée graine).
---
### Collaboration en temps réel
Cliquer sur **Inviter** permet de générer un lien de partage. Deux modes :
- **Éditeur** : peut ajouter des idées, creuser, rejeter.
- **Lecteur** : peut consulter le canvas mais pas le modifier.
Pendant la session :
- Les **curseurs** des autres participants sont visibles en direct (couleur unique par personne).
- Les **avatars** s'affichent dans la barre d'actions avec un indicateur vert "En direct".
- Le flux **Activité** (bouton Activity) liste les actions récentes de chaque participant.
**Modèle host-pays :** l'hôte (créateur de la session) consomme le quota IA ou sa clé BYOK pour tous les participants — les invités n'ont pas besoin de compte payant.
---
### Playback
Le **Playback** (barre en bas du canvas) permet de rejouer l'historique de la session étape par étape, comme un film. Utile pour retracer l'évolution des idées ou présenter le raisonnement à une équipe.
---

View File

@@ -352,6 +352,63 @@ const toolDefinitions = [
inputSchema: { type: 'object', properties: {} },
},
// ═══ TRASH ═══
{
name: 'trash_note',
description: 'Move a note to trash (soft delete). Can be restored later.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
required: ['id'],
},
},
{
name: 'restore_note',
description: 'Restore a trashed note back to the active notes list.',
inputSchema: {
type: 'object',
properties: { id: { type: 'string', description: 'Note ID' } },
required: ['id'],
},
},
{
name: 'get_trash',
description: 'List all notes currently in trash.',
inputSchema: { type: 'object', properties: {} },
},
// ═══ ADVANCED NOTE OPERATIONS ═══
{
name: 'append_to_note',
description: 'Append text to the end of an existing note without replacing its current content.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Note ID' },
content: { type: 'string', description: 'Content to append' },
},
required: ['id', 'content'],
},
},
{
name: 'find_and_update_note',
description: 'Find a note by searching its title/content, then append, prepend, or replace information. Use when you need to update a note found by keyword (e.g. "find the note about bugs and add this info").',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query to find the note' },
newContent: { type: 'string', description: 'Content to add to the note' },
operation: {
type: 'string',
enum: ['append', 'prepend', 'replace'],
description: 'append: add to end, prepend: add to start, replace: overwrite',
default: 'append',
},
},
required: ['query', 'newContent'],
},
},
// ═══ LABELS ═══
{
name: 'create_label',
@@ -538,25 +595,32 @@ export function registerTools(server, prisma) {
}
case 'search_notes': {
const safeQuery = (args.query || '').replace(/'/g, "''");
const userClause = uid ? `AND "userId" = '${uid}'` : '';
const notebookClause = args.notebookId ? `AND "notebookId" = '${args.notebookId.replace(/'/g, "''")}'` : '';
const query = args.query || ''
const params = [query]
let paramIdx = 1
const ftsRows = await prisma.$queryRawUnsafe(`
SELECT id, title, content, color, type, "isPinned", "isArchived",
"isMarkdown", size, "createdAt", "updatedAt", "notebookId",
images, labels, "checkItems", reminder, "isReminderDone"
FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', '${safeQuery}')
AND "trashedAt" IS NULL
AND "isArchived" = ${args.includeArchived ? 'true' : 'false'}
${userClause}
${notebookClause}
ORDER BY ts_rank("tsv", plainto_tsquery('simple', '${safeQuery}')) DESC
LIMIT ${DEFAULT_SEARCH_LIMIT}
`);
const userClause = uid ? `AND "userId" = $${++paramIdx}` : ''
if (uid) params.push(uid)
return textResult(ftsRows.map(parseNoteLightweight));
const notebookClause = args.notebookId ? `AND "notebookId" = $${++paramIdx}` : ''
if (args.notebookId) params.push(args.notebookId)
const ftsRows = await prisma.$queryRawUnsafe(
`SELECT id, title, content, color, type, "isPinned", "isArchived",
"isMarkdown", size, "createdAt", "updatedAt", "notebookId",
images, labels, "checkItems", reminder, "isReminderDone"
FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', $1)
AND "trashedAt" IS NULL
AND "isArchived" = ${args.includeArchived ? 'true' : 'false'}
${userClause}
${notebookClause}
ORDER BY ts_rank("tsv", plainto_tsquery('simple', $1)) DESC
LIMIT ${DEFAULT_SEARCH_LIMIT}`,
...params
)
return textResult(ftsRows.map(parseNoteLightweight))
}
case 'move_note': {
@@ -950,6 +1014,127 @@ export function registerTools(server, prisma) {
return textResult({ count: reminders.length, reminders });
}
// ═══ TRASH ═══
case 'trash_note': {
const note = await prisma.note.update({
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
data: { trashedAt: new Date() },
});
return textResult({ success: true, id: note.id, trashedAt: note.trashedAt });
}
case 'restore_note': {
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}) },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
if (!note.trashedAt) return textResult({ success: true, message: 'Note was not in trash' });
const restored = await prisma.note.update({
where: { id: args.id },
data: { trashedAt: null },
});
return textResult({ success: true, id: restored.id });
}
case 'get_trash': {
const where = { trashedAt: { not: null }, ...(uid ? { userId: uid } : {}) };
const trashed = await prisma.note.findMany({
where,
select: { id: true, title: true, content: true, color: true, trashedAt: true, notebookId: true },
orderBy: { trashedAt: 'desc' },
});
return textResult(trashed.map(n => ({
...n,
content: n.content?.slice(0, 100),
})));
}
// ═══ ADVANCED NOTE OPERATIONS ═══
case 'append_to_note': {
const note = await prisma.note.findUnique({
where: { id: args.id, ...(uid ? { userId: uid } : {}), trashedAt: null },
select: { content: true },
});
if (!note) throw new McpError(ErrorCode.InvalidRequest, 'Note not found');
const updated = await prisma.note.update({
where: { id: args.id },
data: {
content: note.content ? `${note.content}\n\n${args.content}` : args.content,
updatedAt: new Date(),
},
});
return textResult({ success: true, id: updated.id });
}
case 'find_and_update_note': {
const query = args.query || ''
const operation = args.operation || 'append'
const params = [query]
let paramIdx = 1
const userClause = uid ? `AND "userId" = $${++paramIdx}` : ''
if (uid) params.push(uid)
const results = await prisma.$queryRawUnsafe(
`SELECT id, title, content
FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', $1)
AND "trashedAt" IS NULL
AND "isArchived" = false
${userClause}
ORDER BY ts_rank("tsv", plainto_tsquery('simple', $1)) DESC
LIMIT 1`,
...params
)
let note = results[0] || null
if (!note) {
// Fallback: ILIKE search
note = await prisma.note.findFirst({
where: {
trashedAt: null,
isArchived: false,
...(uid ? { userId: uid } : {}),
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
],
},
select: { id: true, title: true, content: true },
})
}
if (!note) {
throw new McpError(ErrorCode.InvalidRequest, `No note found matching "${query}"`)
}
let updatedContent
switch (operation) {
case 'append':
updatedContent = note.content ? `${note.content}\n\n${args.newContent}` : args.newContent
break
case 'prepend':
updatedContent = note.content ? `${args.newContent}\n\n${note.content}` : args.newContent
break
case 'replace':
default:
updatedContent = args.newContent
}
await prisma.note.update({
where: { id: note.id },
data: { content: updatedContent, updatedAt: new Date() },
})
return textResult({
success: true,
noteId: note.id,
noteTitle: note.title || 'Untitled',
operation,
})
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}

View File

@@ -3,7 +3,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { motion } from 'motion/react'
import { Plus, Bot, Search, LifeBuoy } from 'lucide-react'
import { Plus, Bot, Search, LifeBuoy, Menu } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -218,10 +218,18 @@ export function AgentsPageClient({
/>
) : (
<>
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-background/80 backdrop-blur-md z-30">
<div className="flex justify-between items-end">
<div className="space-y-1">
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground">
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-10 md:pt-12 pb-5 sm:pb-6 md:pb-8 flex flex-col gap-4 sm:gap-6 sticky top-0 bg-background/80 backdrop-blur-md z-30">
<div className="flex items-start gap-3 sm:flex-row sm:justify-between sm:items-end">
{/* Hamburger mobile */}
<button
className="md:hidden p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 mt-0.5"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label="Ouvrir la navigation"
>
<Menu size={22} />
</button>
<div className="space-y-1 flex-1 min-w-0">
<h1 className="font-memento-serif text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight text-foreground">
{t('agents.myAgents')}
</h1>
<p className="text-sm text-muted-foreground font-light">
@@ -246,13 +254,13 @@ export function AgentsPageClient({
</div>
</div>
<div className="flex items-center justify-between gap-8 border-b border-border pt-4">
<div className="flex items-center gap-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between sm:gap-8 border-b border-border pt-2 sm:pt-4">
<div className="flex items-center gap-4 sm:gap-8 overflow-x-auto">
{typeFilterOptions.map((opt, i) => (
<button
key={opt.value}
onClick={() => setTypeFilter(opt.value)}
className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative ${
className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative shrink-0 ${
typeFilter === opt.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/60'
}`}
>
@@ -266,7 +274,7 @@ export function AgentsPageClient({
</button>
))}
</div>
<div className="relative pb-4">
<div className="relative pb-4 hidden sm:block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/60" />
<input
type="text"
@@ -279,7 +287,7 @@ export function AgentsPageClient({
</div>
</header>
<div className="px-12 flex-1 pb-20 space-y-12">
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20 space-y-8 sm:space-y-12">
{agents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center bg-card rounded-2xl border border-border/40 shadow-sm">
<Bot className="w-12 h-12 text-muted-foreground/20 mb-4" />

View File

@@ -0,0 +1,17 @@
'use client'
import dynamic from 'next/dynamic'
// D3 uses browser APIs — must be loaded client-side only
const NoteGraphView = dynamic(
() => import('@/components/note-graph-view').then(m => m.NoteGraphView),
{ ssr: false }
)
export default function GraphPage() {
return (
<div className="h-screen overflow-hidden">
<NoteGraphView />
</div>
)
}

View File

@@ -1,5 +1,6 @@
'use client'
import { Menu } from 'lucide-react'
import { SettingsNav } from '@/components/settings'
export default function SettingsLayout({
@@ -9,21 +10,30 @@ export default function SettingsLayout({
}) {
return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper">
<header className="px-12 pt-20 pb-16 space-y-12 shrink-0">
<div className="space-y-4">
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
Paramètres
</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">
Configuration & Préférences
</p>
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
<div className="flex items-start gap-3">
<button
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label="Ouvrir la navigation"
>
<Menu size={22} />
</button>
<div>
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">
Paramètres
</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
Configuration & Préférences
</p>
</div>
</div>
<SettingsNav />
</header>
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto px-12 py-10 space-y-8">
<div className="max-w-5xl mx-auto px-4 sm:px-8 md:px-12 py-6 sm:py-8 md:py-10 space-y-8">
{children}
</div>
</div>

View File

@@ -10,7 +10,7 @@ export default function SupportPage() {
return (
<div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold mb-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4">
{t('support.title')}
</h1>
<p className="text-muted-foreground text-lg">

View File

@@ -13,6 +13,7 @@ import {
Search,
Clock,
AlertCircle,
Menu,
} from 'lucide-react'
import { Note, Notebook } from '@/lib/types'
import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes'
@@ -139,16 +140,26 @@ export function TrashClient({
return (
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#1a1a1a]">
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#1a1a1a]/80 backdrop-blur-md z-30 border-b border-border/20">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-4xl font-memento-serif font-medium text-foreground flex items-center gap-4">
{t('sidebar.trash')} <Trash2 size={28} className="text-rose-400 opacity-40" />
</h1>
<p className="text-[10px] text-muted-foreground font-bold uppercase tracking-[0.3em] opacity-60">
{t('trash.autoDelete30')}
</p>
</div>
<header className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-10 md:pt-12 pb-5 sm:pb-6 md:pb-8 flex flex-col gap-4 sm:gap-6 sticky top-0 bg-[#F9F8F6]/80 dark:bg-[#1a1a1a]/80 backdrop-blur-md z-30 border-b border-border/20">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{/* Hamburger mobile */}
<button
className="md:hidden p-2 -ms-1 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label="Ouvrir la navigation"
>
<Menu size={22} />
</button>
<div className="space-y-1 min-w-0">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-memento-serif font-medium text-foreground flex items-center gap-3">
{t('sidebar.trash')} <Trash2 size={28} className="text-rose-400 opacity-40" />
</h1>
<p className="text-[10px] text-muted-foreground font-bold uppercase tracking-[0.3em] opacity-60">
{t('trash.autoDelete30')}
</p>
</div>
</div>{/* end flex items-center gap-3 */}
{items.length > 0 && (
<button
@@ -161,24 +172,24 @@ export function TrashClient({
)}
</div>
<div className="flex items-center gap-6">
<div className="group relative flex-1 max-w-xl">
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6">
<div className="group relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-foreground transition-colors" size={16} />
<input
type="text"
placeholder={t('common.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-foreground/5 transition-all shadow-sm"
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-3 sm:py-4 text-sm outline-none focus:ring-4 ring-foreground/5 transition-all shadow-sm"
/>
</div>
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm">
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm shrink-0">
{(['all', 'notes', 'notebooks'] as FilterType[]).map(type => (
<button
key={type}
onClick={() => setFilterType(type)}
className={`px-6 py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
className={`px-3 sm:px-6 py-2 sm:py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
${filterType === type ? 'bg-foreground text-background shadow-lg' : 'text-muted-foreground hover:text-foreground'}`}
>
{type === 'all' ? t('trash.filterAll') : type === 'notes' ? t('nav.notes') : t('nav.notebooks')}
@@ -188,9 +199,9 @@ export function TrashClient({
</div>
</header>
<main className="flex-1 px-12 py-12 overflow-y-auto">
<main className="flex-1 px-4 sm:px-8 md:px-12 py-6 sm:py-8 md:py-12 overflow-y-auto">
{items.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 md:gap-8">
<AnimatePresence mode="popLayout">
{items.map(item => {
const daysLeft = getDaysRemaining(item.deletedAt)
@@ -281,7 +292,7 @@ export function TrashClient({
)}
</main>
<footer className="px-12 py-6 bg-white/50 dark:bg-white/5 border-t border-border flex items-center gap-4">
<footer className="px-4 sm:px-8 md:px-12 py-3 sm:py-4 md:py-6 bg-white/50 dark:bg-white/5 border-t border-border flex items-center gap-3">
<AlertCircle size={14} className="text-muted-foreground" />
<p className="text-[10px] text-muted-foreground font-medium uppercase tracking-widest">
{t('trash.notebookRestoreHint')}

View File

@@ -0,0 +1,127 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { parseNote } from '@/lib/utils'
import {
createNoteHistorySnapshot,
parseNoteHistoryEntry,
} from '@/lib/note-history'
export async function getNoteHistory(noteId: string, limit = 30) {
const session = await auth()
if (!session?.user?.id) return []
const clampedLimit = Math.min(Math.max(limit, 1), 100)
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, historyEnabled: true },
})
if (!note || !note.historyEnabled) return []
const entries = await prisma.noteHistory.findMany({
where: { noteId: note.id, userId: session.user.id },
orderBy: { createdAt: 'desc' },
take: clampedLimit,
})
return entries.map(parseNoteHistoryEntry)
}
export async function restoreNoteVersion(noteId: string, historyEntryId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const [note, historyEntry] = await Promise.all([
prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, notebookId: true, historyEnabled: true },
}),
prisma.noteHistory.findFirst({
where: {
id: historyEntryId,
noteId,
userId: session.user.id,
},
}),
])
if (!note || !note.historyEnabled) throw new Error('History is disabled for this note')
if (!historyEntry) throw new Error('History entry not found')
const userId = session.user.id
const restored = await prisma.note.update({
where: { id: note.id, userId },
data: {
title: historyEntry.title,
content: historyEntry.content,
color: historyEntry.color,
isPinned: historyEntry.isPinned,
isArchived: historyEntry.isArchived,
type: historyEntry.type,
checkItems: historyEntry.checkItems,
labels: historyEntry.labels,
images: historyEntry.images,
links: historyEntry.links,
isMarkdown: historyEntry.isMarkdown,
size: historyEntry.size,
notebookId: historyEntry.notebookId,
contentUpdatedAt: new Date(),
},
})
revalidatePath('/home')
return parseNote(restored)
}
export async function commitNoteHistory(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, historyEnabled: true },
})
if (!note) throw new Error('Note not found')
if (!note.historyEnabled) throw new Error('History is disabled for this note')
await createNoteHistorySnapshot({
noteId: note.id,
userId: session.user.id,
reason: 'manual-commit',
})
}
export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const entry = await prisma.noteHistory.findFirst({
where: { id: historyEntryId, noteId, userId: session.user.id },
})
if (!entry) throw new Error('History entry not found')
await prisma.noteHistory.delete({
where: { id: historyEntryId },
})
}
export async function enableNoteHistory(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
})
if (!note) throw new Error('Note not found')
await prisma.note.update({
where: { id: noteId },
data: { historyEnabled: true },
})
}

View File

@@ -0,0 +1,277 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// Add a collaborator to a note (delegates to the share request system)
export async function addCollaborator(noteId: string, userEmail: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const result = await createShareRequest(noteId, userEmail, 'view')
const targetUser = await prisma.user.findUnique({
where: { email: userEmail },
select: { id: true, name: true, email: true, image: true }
})
if (!targetUser) throw new Error('User not found')
return { success: true, user: targetUser }
} catch (error: unknown) {
console.error('Error adding collaborator:', error)
throw error
}
}
export async function removeCollaborator(noteId: string, userId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({ where: { id: noteId } })
if (!note) throw new Error('Note not found')
if (note.userId !== session.user.id) throw new Error('You can only manage collaborators on your own notes')
await prisma.noteShare.deleteMany({ where: { noteId, userId } })
return { success: true }
} catch (error: unknown) {
console.error('Error removing collaborator:', error)
throw new Error(error instanceof Error ? error.message : 'Failed to remove collaborator')
}
}
export async function getNoteCollaborators(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { userId: true }
})
if (!note) throw new Error('Note not found')
if (note.userId !== session.user.id) {
const share = await prisma.noteShare.findUnique({
where: { noteId_userId: { noteId, userId: session.user.id } }
})
if (!share || share.status !== 'accepted') throw new Error('You do not have access to this note')
}
const shares = await prisma.noteShare.findMany({
where: { noteId },
include: { user: { select: { id: true, name: true, email: true, image: true } } }
})
return shares.map(share => share.user)
} catch (error: unknown) {
console.error('Error fetching collaborators:', error)
throw new Error(error instanceof Error ? error.message : 'Failed to fetch collaborators')
}
}
export async function getNoteAllUsers(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { userId: true }
})
if (!note) throw new Error('Note not found')
const share = await prisma.noteShare.findUnique({
where: { noteId_userId: { noteId, userId: session.user.id } }
})
const hasAccess = note.userId === session.user.id || (share && share.status === 'accepted')
if (!hasAccess) throw new Error('You do not have access to this note')
const owner = await prisma.user.findUnique({
where: { id: note.userId! },
select: { id: true, name: true, email: true, image: true }
})
if (!owner) throw new Error('Owner not found')
const shares = await prisma.noteShare.findMany({
where: { noteId, status: 'accepted' },
include: { user: { select: { id: true, name: true, email: true, image: true } } }
})
return [owner, ...shares.map(s => s.user)]
} catch (error: unknown) {
console.error('Error fetching note users:', error)
throw new Error(error instanceof Error ? error.message : 'Failed to fetch note users')
}
}
export async function createShareRequest(
noteId: string,
recipientEmail: string,
permission: 'view' | 'comment' | 'edit' = 'view'
) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({ where: { id: noteId } })
if (!note) throw new Error('Note not found')
if (note.userId !== session.user.id) throw new Error('Only the owner can share notes')
const recipient = await prisma.user.findUnique({ where: { email: recipientEmail } })
if (!recipient) throw new Error('User not found')
if (recipient.id === session.user.id) throw new Error('You cannot share with yourself')
const existingShare = await prisma.noteShare.findUnique({
where: { noteId_userId: { noteId, userId: recipient.id } }
})
if (existingShare) {
if (existingShare.status === 'declined' || existingShare.status === 'removed') {
await prisma.noteShare.update({
where: { id: existingShare.id },
data: { status: 'pending', notifiedAt: new Date(), permission }
})
} else {
throw new Error('Note already shared with this user')
}
} else {
await prisma.noteShare.create({
data: {
noteId,
userId: recipient.id,
sharedBy: session.user.id,
status: 'pending',
permission,
notifiedAt: new Date()
}
})
}
return { success: true }
} catch (error: unknown) {
console.error('Error creating share request:', error)
throw error
}
}
export async function getPendingShareRequests() {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
return await prisma.noteShare.findMany({
where: { userId: session.user.id, status: 'pending' },
include: {
note: { select: { id: true, title: true, content: true, color: true, createdAt: true } },
sharer: { select: { id: true, name: true, email: true, image: true } }
},
orderBy: { createdAt: 'desc' }
})
} catch (error: unknown) {
console.error('Error fetching pending share requests:', error)
throw error
}
}
export async function respondToShareRequest(shareId: string, action: 'accept' | 'decline') {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const share = await prisma.noteShare.findUnique({
where: { id: shareId },
include: { note: true, sharer: true }
})
if (!share) throw new Error('Share request not found')
if (share.userId !== session.user.id) throw new Error('Unauthorized')
const updatedShare = await prisma.noteShare.update({
where: { id: shareId },
data: { status: action === 'accept' ? 'accepted' : 'declined', respondedAt: new Date() },
include: { note: { select: { title: true } } }
})
revalidatePath('/home')
return { success: true, share: updatedShare }
} catch (error: unknown) {
console.error('Error responding to share request:', error)
throw error
}
}
export async function getAcceptedSharedNotes() {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const acceptedShares = await prisma.noteShare.findMany({
where: { userId: session.user.id, status: 'accepted' },
include: { note: true },
orderBy: { createdAt: 'desc' }
})
return acceptedShares.map(share => share.note)
} catch (error: unknown) {
console.error('Error fetching accepted shared notes:', error)
throw error
}
}
export async function removeSharedNoteFromView(shareId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const share = await prisma.noteShare.findUnique({ where: { id: shareId } })
if (!share) throw new Error('Share not found')
if (share.userId !== session.user.id) throw new Error('Unauthorized')
await prisma.noteShare.update({ where: { id: shareId }, data: { status: 'removed' } })
revalidatePath('/home')
return { success: true }
} catch (error: unknown) {
console.error('Error removing shared note from view:', error)
throw error
}
}
export async function getPendingShareCount() {
const session = await auth()
if (!session?.user?.id) return 0
try {
return await prisma.noteShare.count({
where: { userId: session.user.id, status: 'pending' }
})
} catch (error) {
console.error('Error getting pending share count:', error)
return 0
}
}
export async function leaveSharedNote(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const share = await prisma.noteShare.findUnique({
where: { noteId_userId: { noteId, userId: session.user.id } }
})
if (!share) throw new Error('Share not found')
if (share.userId !== session.user.id) throw new Error('Unauthorized')
await prisma.noteShare.update({ where: { id: share.id }, data: { status: 'removed' } })
revalidatePath('/home')
return { success: true }
} catch (error: unknown) {
console.error('Error leaving shared note:', error)
throw error
}
}

View File

@@ -0,0 +1,224 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { parseNote } from '@/lib/utils'
import { parseImageUrls, cleanupNoteImages, deleteImageFileSafely } from '@/lib/image-cleanup'
import { NOTE_LIST_SELECT } from '@/lib/note-select'
// Soft-delete a note (move to trash)
export async function deleteNote(id: string, options?: { skipRevalidation?: boolean }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { success: true }
} catch (error) {
console.error('Error deleting note:', error)
throw new Error('Failed to delete note')
}
}
export async function trashNote(id: string, options?: { skipRevalidation?: boolean }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { success: true }
} catch (error) {
console.error('Error trashing note:', error)
throw new Error('Failed to trash note')
}
}
export async function restoreNote(id: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: null }
})
revalidatePath('/home')
revalidatePath('/trash')
return { success: true }
} catch (error) {
console.error('Error restoring note:', error)
throw new Error('Failed to restore note')
}
}
export async function getTrashedNotes() {
const session = await auth()
if (!session?.user?.id) return []
try {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
},
select: NOTE_LIST_SELECT,
orderBy: { trashedAt: 'desc' }
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching trashed notes:', error)
return []
}
}
export async function permanentDeleteNote(id: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { images: true }
})
const imageUrls = parseImageUrls(note?.images ?? null)
await prisma.note.delete({ where: { id, userId: session.user.id } })
if (imageUrls.length > 0) {
await cleanupNoteImages(id, imageUrls)
}
revalidatePath('/trash')
revalidatePath('/home')
return { success: true }
} catch (error) {
console.error('Error permanently deleting note:', error)
throw new Error('Failed to permanently delete note')
}
}
export async function emptyTrash() {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const trashedNotes = await prisma.note.findMany({
where: { userId: session.user.id, trashedAt: { not: null } },
select: { id: true, images: true }
})
await prisma.note.deleteMany({
where: { userId: session.user.id, trashedAt: { not: null } }
})
for (const note of trashedNotes) {
const imageUrls = parseImageUrls(note.images)
if (imageUrls.length > 0) {
await cleanupNoteImages(note.id, imageUrls)
}
}
await prisma.notebook.deleteMany({
where: { userId: session.user.id, trashedAt: { not: null } }
})
revalidatePath('/trash')
revalidatePath('/home')
return { success: true }
} catch (error) {
console.error('Error emptying trash:', error)
throw new Error('Failed to empty trash')
}
}
export async function removeImageFromNote(noteId: string, imageIndex: number) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({
where: { id: noteId, userId: session.user.id },
select: { images: true },
})
if (!note) throw new Error('Note not found')
const imageUrls = parseImageUrls(note.images)
if (imageIndex < 0 || imageIndex >= imageUrls.length) throw new Error('Invalid image index')
const removedUrl = imageUrls[imageIndex]
const newImages = imageUrls.filter((_, i) => i !== imageIndex)
await prisma.note.update({
where: { id: noteId },
data: { images: newImages.length > 0 ? JSON.stringify(newImages) : null },
})
await deleteImageFileSafely(removedUrl, noteId)
return { success: true }
} catch (error) {
console.error('Error removing image:', error)
throw new Error('Failed to remove image')
}
}
export async function cleanupOrphanedImages(imageUrls: string[], noteId: string) {
const session = await auth()
if (!session?.user?.id) return
try {
for (const url of imageUrls) {
await deleteImageFileSafely(url, noteId)
}
} catch {
// Silent — best-effort cleanup
}
}
export async function getTrashCount() {
const session = await auth()
if (!session?.user?.id) return 0
try {
const [noteCount, notebookCount] = await Promise.all([
prisma.note.count({
where: { userId: session.user.id, trashedAt: { not: null } }
}),
prisma.notebook.count({
where: { userId: session.user.id, trashedAt: { not: null } }
})
])
return noteCount + notebookCount
} catch {
return 0
}
}
export async function getTrashedNotebooks() {
const session = await auth()
if (!session?.user?.id) return []
try {
return await prisma.notebook.findMany({
where: { userId: session.user.id, trashedAt: { not: null } },
include: { _count: { select: { notes: true } } },
orderBy: { trashedAt: 'desc' }
})
} catch (error) {
console.error('Error fetching trashed notebooks:', error)
return []
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,9 @@ const TYPE_DEFAULTS: Record<GenerateType, {
maxSteps: number
}> = {
'slide-generator': {
role: 'Crée une présentation PowerPoint professionnelle et visuelle à partir du contenu de la note fournie.',
tools: ['note_search', 'note_read', 'generate_pptx'],
maxSteps: 8,
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 6,
},
'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
@@ -30,12 +30,13 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
const body = await req.json()
const { noteId, type, theme, style, language } = body as {
const { noteId, type, theme, style, language, template } = body as {
noteId: string
type: GenerateType
theme?: string
style?: string
language?: string
template?: string
}
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
@@ -56,10 +57,14 @@ export async function POST(req: NextRequest) {
let role = defaults.role
if (isEn) {
if (type === 'slide-generator') {
role = 'Create a professional and visual PowerPoint presentation from the provided note content.'
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
} else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
}
} else if (type === 'slide-generator') {
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
}
const agentName = type === 'slide-generator'
@@ -71,13 +76,14 @@ export async function POST(req: NextRequest) {
name: agentName,
type,
role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps,
frequency: 'one-shot',
isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined,
slideTheme: theme ?? 'vibrant_tech',
slideTheme: theme ?? 'keynote',
slideStyle: style ?? 'soft',
userId,
},
@@ -118,7 +124,7 @@ export async function GET(req: NextRequest) {
const action = await prisma.agentAction.findFirst({
where: { agentId },
orderBy: { createdAt: 'desc' },
select: { id: true, status: true, result: true, log: true },
select: { id: true, status: true, result: true, log: true, createdAt: true },
})
if (!action) {
@@ -126,6 +132,19 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ status: 'running' })
}
// Detect stale running actions (killed by process restart or hot-reload)
const GENERATION_TIMEOUT_MS = 8 * 60 * 1000 // 8 minutes
if (action.status === 'running') {
const ageMs = Date.now() - new Date(action.createdAt).getTime()
if (ageMs > GENERATION_TIMEOUT_MS) {
await prisma.agentAction.update({
where: { id: action.id },
data: { status: 'failure', log: 'Generation timed out (process restart)' },
}).catch(() => { /* best-effort */ })
return NextResponse.json({ status: 'failure', actionId: action.id, error: 'La génération a expiré. Veuillez réessayer.' })
}
}
// If success, find canvasId from the related canvas (result stores canvas id)
let canvasId: string | null = null
let noteId: string | null = null

View File

@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
interface PersonaDef {
id: PersonaId
label: string
description: string
systemPrompt: string
}
const PERSONAS: PersonaDef[] = [
{
id: 'engineer',
label: 'Ingénieur',
description: 'Faisabilité technique, complexité, risques d\'implémentation',
systemPrompt: `Tu es un ingénieur senior pragmatique et rigoureux. Analyse les notes ci-dessous en te concentrant sur :
la faisabilité technique, les risques d'implémentation, la complexité cachée, les dépendances et contraintes techniques.
Fournis 3 à 5 commentaires concis et 2 à 3 questions techniques précises qui obligent à réfléchir plus profondément.
Réponds dans la même langue que la note. Format : commentaires en liste puis questions en liste.`,
},
{
id: 'financial',
label: 'Financier',
description: 'ROI, coûts, viabilité économique, risques financiers',
systemPrompt: `Tu es un analyste financier exigeant. Analyse les notes ci-dessous sous l'angle économique :
ROI potentiel, coûts cachés, modèle de revenus, risques financiers, allocation de ressources, viabilité à long terme.
Fournis 3 à 5 observations financières et 2 à 3 questions qui remettent en cause les hypothèses économiques.
Réponds dans la même langue que la note. Format : observations en liste puis questions en liste.`,
},
{
id: 'customer',
label: 'Client',
description: 'Expérience utilisateur, besoins réels, valeur perçue',
systemPrompt: `Tu es un client exigeant qui recherche de la valeur concrète. Analyse les notes ci-dessous du point de vue de l'utilisateur final :
clarté de la valeur ajoutée, frictions dans l'expérience, besoins non satisfaits, adéquation au problème réel.
Fournis 3 à 5 réactions authentiques de client et 2 à 3 questions sur l'utilité réelle.
Réponds dans la même langue que la note. Format : réactions en liste puis questions en liste.`,
},
{
id: 'skeptic',
label: 'Sceptique',
description: 'Hypothèses contestables, points faibles, angles morts',
systemPrompt: `Tu es un expert sceptique et critique constructif. Analyse les notes ci-dessous en cherchant :
les hypothèses non validées, les points faibles, les angles morts, les contradictions internes, les risques ignorés.
Sois direct et précis. Fournis 3 à 5 critiques fondées et 2 à 3 questions qui remettent en cause les fondements.
Réponds dans la même langue que la note. Format : critiques en liste puis questions en liste.`,
},
{
id: 'optimist',
label: 'Optimiste',
description: 'Opportunités sous-estimées, potentiel inexploité, synergies',
systemPrompt: `Tu es un stratège visionnaire et enthousiaste. Analyse les notes ci-dessous pour identifier :
les opportunités sous-estimées, le potentiel inexploité, les synergies possibles, les effets de levier, les succès rapides atteignables.
Fournis 3 à 5 opportunités concrètes et 2 à 3 questions qui ouvrent de nouvelles perspectives.
Réponds dans la même langue que la note. Format : opportunités en liste puis questions en liste.`,
},
]
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { content, title, personaId } = await request.json()
if (!content || typeof content !== 'string') {
return NextResponse.json({ error: 'Content is required' }, { status: 400 })
}
if (!personaId) {
return NextResponse.json({ error: 'personaId is required' }, { status: 400 })
}
const persona = PERSONAS.find(p => p.id === personaId)
if (!persona) {
return NextResponse.json({ error: 'Invalid personaId' }, { status: 400 })
}
// Quota check (reuse reformulate quota)
try {
await checkEntitlementOrThrow(session.user.id, 'reformulate')
} catch (err) {
if (err instanceof QuotaExceededError) {
const isTierLocked = err.currentQuota === 0
return NextResponse.json({
error: isTierLocked ? 'feature_locked' : 'quota_exceeded',
errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded',
upgradeTier: err.upgradeTier,
quotaExceeded: true,
}, { status: 402 })
}
throw err
}
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const noteText = title ? `Titre : ${title}\n\n${content}` : content
// Strip HTML tags for cleaner analysis
const plainText = noteText.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
const fullPrompt = `${persona.systemPrompt}\n\n---\nNOTE À ANALYSER :\n${plainText}`
const result = await provider.generateText(fullPrompt)
incrementUsageAsync(session.user.id, 'reformulate')
return NextResponse.json({
personaId: persona.id,
personaLabel: persona.label,
analysis: result,
})
} catch (error) {
console.error('Personas API error:', error)
return NextResponse.json({ error: 'Failed to generate persona analysis' }, { status: 500 })
}
}
// GET — return available personas (no auth needed)
export async function GET() {
return NextResponse.json({
personas: PERSONAS.map(({ id, label, description }) => ({ id, label, description })),
})
}

View File

@@ -95,6 +95,16 @@ async function getParentContext(
return { notes, noteIds }
}
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
}
return map[locale ?? ''] ?? 'English'
}
function buildExpandPromptV2(
parentTitle: string,
parentDesc: string,
@@ -157,7 +167,7 @@ RESPOND ONLY with a valid JSON array of 9 objects:
CRITICAL: Each idea MUST have at least 1 noteRef when notes are provided.
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${localeToLanguageName(locale)}.`
}
export async function POST(

View File

@@ -161,6 +161,7 @@ export async function PATCH(
const updates: Record<string, any> = {}
if (typeof body.isPublic === 'boolean') updates.isPublic = body.isPublic
if (typeof body.guestCanEdit === 'boolean') updates.guestCanEdit = body.guestCanEdit
if (typeof body.seedIdea === 'string' && body.seedIdea.trim()) updates.seedIdea = body.seedIdea.trim()
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { z } from 'zod'
import { verifyParticipant } from '@/lib/brainstorm-collab'
import { emitToSession } from '@/lib/socket-emit'
const starSchema = z.object({
ideaId: z.string().min(1),
})
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { sessionId } = await params
const body = await request.json()
const { ideaId } = starSchema.parse(body)
const brainstormSession = await prisma.brainstormSession.findFirst({
where: { id: sessionId },
})
if (!brainstormSession) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'editor')
if (!isParticipant) {
return NextResponse.json({ error: 'No edit permission' }, { status: 403 })
}
const idea = await prisma.brainstormIdea.findFirst({
where: { id: ideaId, sessionId },
})
if (!idea) {
return NextResponse.json({ error: 'Idea not found' }, { status: 404 })
}
const updated = await prisma.brainstormIdea.update({
where: { id: ideaId },
data: { isStarred: !idea.isStarred },
})
await emitToSession(sessionId, 'idea:starred', {
ideaId,
isStarred: updated.isStarred,
userId: session.user.id,
})
return NextResponse.json({ success: true, data: { ideaId, isStarred: updated.isStarred } })
} catch (error) {
console.error('Error starring idea:', error)
return NextResponse.json({ error: 'Failed to star idea' }, { status: 500 })
}
}

View File

@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { runLaneWithBillingUser } from '@/lib/ai/provider-for-user'
import { getSystemConfig } from '@/lib/config'
import { verifyParticipant } from '@/lib/brainstorm-collab'
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
}
return map[locale ?? ''] ?? 'English'
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ sessionId: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { sessionId } = await params
const body = await request.json().catch(() => ({}))
const locale: string = body.locale || 'en'
const brainstormSession = await prisma.brainstormSession.findFirst({
where: { id: sessionId },
include: {
ideas: {
where: { status: { not: 'dismissed' } },
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
},
},
})
if (!brainstormSession) {
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
}
const { isParticipant } = await verifyParticipant(sessionId, session.user.id, 'viewer')
if (!isParticipant) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
const ideas = brainstormSession.ideas
if (ideas.length === 0) {
return NextResponse.json({ error: 'No ideas to summarize' }, { status: 400 })
}
const ideasText = ideas
.map((idea, i) => `${i + 1}. [Wave ${idea.waveNumber}] ${idea.title}: ${idea.description}`)
.join('\n')
const starredIdeas = ideas.filter(i => i.isStarred)
const starredText = starredIdeas.length > 0
? `\nStarred/favorite ideas: ${starredIdeas.map(i => i.title).join(', ')}`
: ''
const language = localeToLanguageName(locale)
const prompt = `You are summarizing a brainstorming session. Respond in ${language}.
Seed idea: "${brainstormSession.seedIdea}"
Ideas generated (${ideas.length} total):
${ideasText}${starredText}
Write a concise synthesis (4-6 sentences) that:
1. Captures the main themes and directions explored
2. Highlights the most promising ideas or clusters
3. Suggests a concrete next step
Be direct and action-oriented. No bullet points, just flowing prose.`
const config = await getSystemConfig()
const { result: summary } = await runLaneWithBillingUser(
'tags',
config,
session.user.id,
(provider) => provider.generateText(prompt),
)
return NextResponse.json({ success: true, data: { summary: summary.trim() } })
} catch (error) {
console.error('Error summarizing brainstorm:', error)
return NextResponse.json({ error: 'Failed to summarize' }, { status: 500 })
}
}

View File

@@ -109,6 +109,16 @@ Respond ONLY with a valid JSON array of objects:
}
}
function localeToLanguageName(locale?: string): string {
const map: Record<string, string> = {
en: 'English', fr: 'French', es: 'Spanish', de: 'German',
it: 'Italian', pt: 'Portuguese', nl: 'Dutch', ru: 'Russian',
zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic',
fa: 'Farsi', hi: 'Hindi', pl: 'Polish',
}
return map[locale ?? ''] ?? 'English'
}
function buildPromptV2(seedIdea: string, classifiedNotes: ClassifiedNote[], locale?: string): string {
const supportNotes = classifiedNotes.filter(n => n.category === 'SUPPORT')
const tensionNotes = classifiedNotes.filter(n => n.category === 'TENSION')
@@ -168,7 +178,7 @@ RESPOND ONLY with a valid JSON array of 9 objects:
CRITICAL: Each idea MUST have at least 1 noteRef. Only use null noteId if genuinely no note connects to that idea.
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${locale === 'fr' ? 'French' : locale === 'es' ? 'Spanish' : locale === 'de' ? 'German' : locale === 'it' ? 'Italian' : locale === 'pt' ? 'Portuguese' : locale === 'nl' ? 'Dutch' : locale === 'ru' ? 'Russian' : locale === 'zh' ? 'Chinese' : locale === 'ja' ? 'Japanese' : locale === 'ko' ? 'Korean' : locale === 'ar' ? 'Arabic' : locale === 'fa' ? 'Farsi' : locale === 'hi' ? 'Hindi' : locale === 'pl' ? 'Polish' : 'the same language as the seed idea'}.`
LANGUAGE: You MUST write ALL titles, descriptions, connectionToSeed, and explanation fields in ${localeToLanguageName(locale)}.`
}
function buildFallbackIdeas(classifiedNotes: ClassifiedNote[]): any[] {

View File

@@ -0,0 +1,528 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import PptxGenJS from 'pptxgenjs'
// ─── Color helpers ─────────────────────────────────────────────────────────
const hex = (c: string) => c.replace('#', '').toUpperCase()
interface PptxPalette {
primary: string; secondary: string; accent: string
bg: string; text: string; muted: string; isDark: boolean
}
function resolvePalette(theme?: string): PptxPalette {
const palettes: Record<string, PptxPalette> = {
'midnight-cathedral': { primary: '0A0F1E', secondary: 'C9A84C', accent: 'E8D5B5', bg: '0A0F1E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'aurora-borealis': { primary: '0F0A2A', secondary: '7C3AED', accent: '06B6D4', bg: '0F0A2A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'tokyo-neon': { primary: '0A0A0F', secondary: 'FF006E', accent: '3A86FF', bg: '0A0A0F', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'venture-pitch': { primary: '18181B', secondary: 'F97316', accent: '14B8A6', bg: '18181B', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'forest-floor': { primary: '0D1B0E', secondary: '22C55E', accent: 'A3B18A', bg: '0D1B0E', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'steel-glass': { primary: '292524', secondary: 'D4C5A9', accent: '94A3B8', bg: '292524', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'cyberpunk-terminal': { primary: '0A0A0A', secondary: '00FF41', accent: 'FFA500', bg: '0A0A0A', text: 'F1F5F9', muted: '94A3B8', isDark: true },
'sunlit-gallery': { primary: 'FAF7F0', secondary: 'D4A574', accent: '5B9BD5', bg: 'FAF7F0', text: '1C1C1C', muted: '64748B', isDark: false },
'clinical-precision': { primary: 'F8FAFC', secondary: '0891B2', accent: '34D399', bg: 'F8FAFC', text: '0F172A', muted: '64748B', isDark: false },
'editorial-ink': { primary: 'FFFCF5', secondary: '1A1A2E', accent: '800020', bg: 'FFFCF5', text: '1A1A2E', muted: '64748B', isDark: false },
'coastal-morning': { primary: 'F0F7FF', secondary: '2563EB', accent: 'F97066', bg: 'F0F7FF', text: '0F172A', muted: '64748B', isDark: false },
'paper-studio': { primary: 'FEFCF8', secondary: '1E293B', accent: 'C2410C', bg: 'FEFCF8', text: '1E293B', muted: '64748B', isDark: false },
'architectural-saas': { primary: 'F2F0E9', secondary: 'A47148', accent: '4A4E69', bg: 'F2F0E9', text: '1C1C1C', muted: '64748B', isDark: false },
}
const key = (theme || 'architectural-saas').toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-')
return palettes[key] ?? palettes['architectural-saas']
}
// ─── PPTX Builder (new JSON format) ────────────────────────────────────────
async function buildPptx(spec: any): Promise<Buffer> {
const pptx = new PptxGenJS()
pptx.layout = 'LAYOUT_WIDE'
pptx.title = spec.title || 'Presentation'
pptx.subject = spec.title || 'Presentation'
const p = resolvePalette(spec.theme)
const W = 13.33, H = 7.5
for (const slide of (spec.slides ?? [])) {
const s = pptx.addSlide()
switch (slide.type) {
case 'title': {
s.background = { color: p.isDark ? p.primary : p.secondary }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: W, h: 0.08,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(slide.title || spec.title, {
x: 1, y: 2, w: W - 2, h: 2,
fontSize: 42, color: p.isDark ? 'FFFFFF' : 'FFFFFF', bold: true, align: 'center',
})
if (slide.subtitle) {
s.addText(slide.subtitle, {
x: 1, y: 4.3, w: W - 2, h: 1,
fontSize: 20, color: 'FFFFFF', align: 'center', transparency: 20,
})
}
s.addShape(pptx.ShapeType.rect, {
x: 0, y: H - 0.08, w: W, h: 0.08,
fill: { color: p.accent }, line: { type: 'none' },
})
break
}
case 'bullets': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
const bullets = (slide.items ?? []).map((item: string) => ({
text: item,
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
}))
if (bullets.length) {
s.addText(bullets, {
x: 0.5, y: 1.6, w: W - 1, h: H - 2,
fontSize: 17, color: p.text,
})
}
break
}
case 'chart': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
if (slide.subtitle) {
s.addText(slide.subtitle, {
x: 0.5, y: 1.2, w: W - 1, h: 0.5,
fontSize: 12, color: p.muted,
})
}
const data = slide.data ?? []
if (data.length > 0) {
const chartType = slide.chartType === 'line' ? pptx.ChartType.line
: slide.chartType === 'donut' ? pptx.ChartType.doughnut
: slide.chartType === 'radar' ? pptx.ChartType.radar
: pptx.ChartType.bar
const chartData = [{
name: slide.title,
labels: data.map((d: any) => d.label),
values: data.map((d: any) => d.value),
}]
s.addChart(chartType, chartData, {
x: 0.8, y: 1.8, w: W - 1.6, h: H - 2.5,
showValue: true,
showTitle: false,
showLegend: false,
chartColors: [p.secondary, p.accent, '10B981', 'F59E0B', 'EF4444', '6366F1'],
valAxisHidden: slide.chartType === 'donut' || slide.chartType === 'radar',
catAxisHidden: slide.chartType === 'donut',
})
}
break
}
case 'stats': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const stats = (slide.stats ?? []).slice(0, 4)
const statW = (W - 1 - (stats.length - 1) * 0.3) / stats.length
stats.forEach((st: any, i: number) => {
const x = 0.5 + i * (statW + 0.3)
s.addShape(pptx.ShapeType.rect, {
x, y: 2, w: statW, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(st.value, {
x, y: 2.3, w: statW, h: 2,
fontSize: 48, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true, align: 'center',
})
s.addText(st.label, {
x, y: 4.4, w: statW, h: 0.7,
fontSize: 13, color: p.muted, align: 'center',
})
})
break
}
case 'table': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const headers = slide.headers ?? []
const rows = slide.rows ?? []
if (headers.length > 0) {
const tableRows = [
headers.map((h: string) => ({ text: h, options: { bold: true, color: 'FFFFFF', fill: { color: p.secondary } } })),
...rows.map((row: string[], ri: number) =>
row.map((cell: string) => ({ text: cell, options: { fill: { color: ri % 2 === 0 ? 'F8F8F8' : 'FFFFFF' } } }))
),
]
s.addTable(tableRows, {
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
fontSize: 13, color: p.text,
border: { type: 'solid', pt: 0.5, color: 'DDDDDD' },
colW: Array(headers.length).fill((W - 1) / headers.length),
})
}
break
}
case 'cards': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 26, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const cards = (slide.cards ?? []).slice(0, 6)
const cols = cards.length <= 2 ? 2 : cards.length <= 3 ? 3 : cards.length === 4 ? 2 : 3
const cardW = (W - 1 - (cols - 1) * 0.2) / cols
const cardH = cards.length > cols ? 2.3 : 3.5
cards.forEach((card: any, i: number) => {
const col = i % cols
const row = Math.floor(i / cols)
const x = 0.5 + col * (cardW + 0.2)
const y = 1.5 + row * (cardH + 0.15)
s.addShape(pptx.ShapeType.roundRect, {
x, y, w: cardW, h: cardH,
fill: { color: p.isDark ? '1E1E2E' : 'FFFFFF' },
line: { color: p.isDark ? '333333' : 'E5E7EB', pt: 1 },
rectRadius: 0.08,
})
s.addShape(pptx.ShapeType.rect, {
x, y, w: cardW, h: 0.05,
fill: { color: p.accent }, line: { type: 'none' },
})
s.addText(card.title, {
x: x + 0.15, y: y + 0.25, w: cardW - 0.3, h: 0.6,
fontSize: 14, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addText(card.description, {
x: x + 0.15, y: y + 0.8, w: cardW - 0.3, h: cardH - 1,
fontSize: 12, color: p.text,
})
})
break
}
case 'timeline': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const events = (slide.events ?? []).slice(0, 6)
const evH = Math.min(0.9, (H - 2) / events.length)
events.forEach((ev: any, i: number) => {
const y = 1.6 + i * evH
// Dot
s.addShape(pptx.ShapeType.ellipse, {
x: 0.7, y: y + 0.1, w: 0.2, h: 0.2,
fill: { color: p.accent }, line: { type: 'none' },
})
// Line
if (i < events.length - 1) {
s.addShape(pptx.ShapeType.rect, {
x: 0.78, y: y + 0.32, w: 0.04, h: evH - 0.2,
fill: { color: p.accent }, line: { type: 'none' },
})
}
s.addText(ev.date, {
x: 1.1, y, w: 2, h: 0.4,
fontSize: 10, color: p.accent, bold: true,
})
s.addText(ev.title, {
x: 1.1, y: y + 0.3, w: W - 2, h: 0.4,
fontSize: 14, color: p.text, bold: true,
})
if (ev.description) {
s.addText(ev.description, {
x: 1.1, y: y + 0.6, w: W - 2, h: 0.3,
fontSize: 11, color: p.muted,
})
}
})
break
}
case 'quote': {
const qBg = p.isDark ? '0D1117' : '1A1A2E'
s.background = { color: qBg }
s.addText('\u201C', {
x: 0.5, y: 0.2, w: 2, h: 2,
fontSize: 96, color: p.accent, bold: true, transparency: 60,
})
s.addText(slide.quote, {
x: 1, y: 1.5, w: W - 2, h: 3.5,
fontSize: 24, color: 'FFFFFF', italic: true, align: 'left',
})
if (slide.author) {
s.addText(`\u2014 ${slide.author}`, {
x: 1, y: 5.2, w: W - 2, h: 0.8,
fontSize: 14, color: p.accent, align: 'left',
})
}
if (slide.context) {
s.addText(slide.context, {
x: 1, y: 6, w: W - 2, h: 0.8,
fontSize: 12, color: 'AAAAAA',
})
}
break
}
case 'comparison': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const colW = (W - 1.5) / 2
const colY = 1.5, colH = H - 2
// Left
if (slide.left) {
s.addShape(pptx.ShapeType.roundRect, {
x: 0.5, y: colY, w: colW, h: colH,
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
line: { color: p.secondary, pt: 1 },
rectRadius: 0.08,
})
s.addText(slide.left.title, {
x: 0.7, y: colY + 0.15, w: colW - 0.4, h: 0.5,
fontSize: 14, color: p.secondary, bold: true,
})
const lBullets = (slide.left.points ?? []).map((pt: string) => ({
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
}))
s.addText(lBullets, {
x: 0.7, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
fontSize: 13, color: p.text,
})
if (slide.left.score) {
s.addText(slide.left.score, {
x: 0.7, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
fontSize: 18, color: p.secondary, bold: true, align: 'center',
})
}
}
// Right
if (slide.right) {
const rx = 0.5 + colW + 0.5
s.addShape(pptx.ShapeType.roundRect, {
x: rx, y: colY, w: colW, h: colH,
fill: { color: p.isDark ? '1E1E2E' : 'F8FAFC' },
line: { color: p.accent, pt: 1 },
rectRadius: 0.08,
})
s.addText(slide.right.title, {
x: rx + 0.2, y: colY + 0.15, w: colW - 0.4, h: 0.5,
fontSize: 14, color: p.accent, bold: true,
})
const rBullets = (slide.right.points ?? []).map((pt: string) => ({
text: pt, options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 4 },
}))
s.addText(rBullets, {
x: rx + 0.2, y: colY + 0.7, w: colW - 0.4, h: colH - 1.4,
fontSize: 13, color: p.text,
})
if (slide.right.score) {
s.addText(slide.right.score, {
x: rx + 0.2, y: colY + colH - 0.6, w: colW - 0.4, h: 0.5,
fontSize: 18, color: p.accent, bold: true, align: 'center',
})
}
}
break
}
case 'equation': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const eqs = (slide.equations ?? []).slice(0, 4)
const eqH = Math.min(1.5, (H - 2.5) / eqs.length)
eqs.forEach((eq: any, i: number) => {
const y = 1.8 + i * eqH
s.addText(eq.latex, {
x: 1, y, w: W - 2, h: eqH * 0.6,
fontSize: 28, color: p.text, align: 'center', fontFace: 'Cambria Math',
})
if (eq.label) {
s.addText(eq.label, {
x: 1, y: y + eqH * 0.6, w: W - 2, h: eqH * 0.3,
fontSize: 11, color: p.muted, align: 'center',
})
}
})
if (slide.explanation) {
s.addText(slide.explanation, {
x: 1, y: H - 1.2, w: W - 2, h: 0.8,
fontSize: 12, color: p.muted, align: 'center',
})
}
break
}
case 'image': {
s.background = { color: p.bg }
s.addText(slide.title, {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
if (slide.url) {
try {
s.addImage({
path: slide.url,
x: 2, y: 1.5, w: W - 4, h: H - 2.5,
sizing: { type: 'contain', w: W - 4, h: H - 2.5 },
})
} catch { /* image unavailable */ }
}
if (slide.caption) {
s.addText(slide.caption, {
x: 0.5, y: H - 0.9, w: W - 1, h: 0.7,
fontSize: 12, color: p.muted, align: 'center',
})
}
break
}
case 'summary': {
s.background = { color: p.bg }
s.addShape(pptx.ShapeType.rect, {
x: 0, y: 0, w: 0.06, h: H,
fill: { color: p.secondary }, line: { type: 'none' },
})
s.addText(slide.title || 'En résumé', {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y: 1.35, w: 0.5, h: 0.06,
fill: { color: p.accent }, line: { type: 'none' },
})
const items = (slide.items ?? []).slice(0, 8)
items.forEach((item: string, i: number) => {
const y = 1.6 + i * 0.7
s.addShape(pptx.ShapeType.rect, {
x: 0.5, y, w: W - 1, h: 0.55,
fill: { color: i % 2 === 0 ? (p.isDark ? '1E1E2E' : 'F5F5F5') : (p.isDark ? '151525' : 'EBEBEB') },
line: { type: 'none' },
})
s.addText('\u2713', {
x: 0.7, y, w: 0.5, h: 0.55,
fontSize: 14, color: p.accent, bold: true, valign: 'middle',
})
s.addText(item, {
x: 1.3, y, w: W - 2, h: 0.55,
fontSize: 14, color: p.text, valign: 'middle',
})
})
break
}
// Fallback: treat as bullets
default: {
s.background = { color: p.bg }
s.addText(slide.title || '', {
x: 0.5, y: 0.4, w: W - 1, h: 0.9,
fontSize: 28, color: p.isDark ? 'FFFFFF' : p.secondary, bold: true,
})
const content = slide.items || slide.content || []
if (Array.isArray(content) && content.length) {
const defBullets = content.map((item: string) => ({
text: String(item),
options: { bullet: { type: 'bullet' as const }, paraSpaceAfter: 6 },
}))
s.addText(defBullets, {
x: 0.5, y: 1.5, w: W - 1, h: H - 2,
fontSize: 17, color: p.text,
})
}
}
}
}
return pptx.write({ outputType: 'nodebuffer' }) as Promise<Buffer>
}
// ─── API Route ─────────────────────────────────────────────────────────────
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const canvasId = req.nextUrl.searchParams.get('id')
if (!canvasId) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 })
}
const canvas = await prisma.canvas.findUnique({
where: { id: canvasId, userId: session.user.id },
})
if (!canvas) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
let parsed: any
try {
parsed = JSON.parse(canvas.data)
} catch {
return NextResponse.json({ error: 'Invalid canvas data' }, { status: 500 })
}
if (parsed.type !== 'slides') {
return NextResponse.json({ error: 'Not a slides canvas' }, { status: 400 })
}
// Support both new format (spec embedded) and legacy (spec at root)
const spec = parsed.spec || parsed
if (!spec || !Array.isArray(spec.slides)) {
return NextResponse.json({ error: 'No slide data found — please regenerate the presentation' }, { status: 400 })
}
const buffer = await buildPptx(spec)
const filename = `${(canvas.name || 'presentation').replace(/[^a-zA-Z0-9-_ ]/g, '_').trim()}.pptx`
return new NextResponse(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': String(buffer.length),
},
})
} catch (err: any) {
console.error('[PPTX Export]', err)
return NextResponse.json({ error: err.message || 'Export failed' }, { status: 500 })
}
}

View File

@@ -220,10 +220,11 @@ Momento is an intelligent note-taking application. Key features include:
- **Lab**: Experimental AI tools for data analysis and deeper insights.
## Available tools
You have access to: note_search, note_read, document_search, task_extract, web_search, web_scrape.
You have access to: note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape.
Only use tools if you need more information. Never invent note IDs or URLs.
- document_search: Searches attached PDF documents for the current note/notebook. Use when the user asks about documents or files.
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.`,
- task_extract: Extracts action items from notes and creates a synthesis note. Use when the user asks to extract tasks or TODOs.
- note_find_and_update: Finds a note by search query and appends/prepends/replaces content. Use when the user says "find the note about X and add Y to it".`,
},
fr: {
contextWithNotes: `## Notes et documents de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Pour les documents PDF, cite le nom du fichier et la page, ex: "Le chiffre d'affaires est de 5M$ (📄 rapport.pdf p.12)". Ne recopie pas mot pour mot — reformule.`,
@@ -254,9 +255,10 @@ Only use tools if you need more information. Never invent note IDs or URLs.
Momento est une application de prise de notes intelligente. Ses fonctionnalités : Éditeur Markdown riche, Copilot IA, Organisation par Carnets, Recherche sémantique, Agents IA, Lab.
## Outils disponibles
Tu as accès à : note_search, note_read, document_search, task_extract, web_search, web_scrape.
Tu as accès à : note_search, note_read, note_find_and_update, document_search, task_extract, web_search, web_scrape.
- document_search : Recherche dans les documents PDF attachés à la note/au carnet.
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.`,
- task_extract : Extrait les tâches/action items des notes et crée une note de synthèse.
- note_find_and_update : Trouve une note par recherche textuelle et ajoute/prépose/remplace du contenu. Utilise quand l'utilisateur dit "trouve la note sur X et ajoute-y Y".`,
},
fa: {
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`,

View File

@@ -0,0 +1,138 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// ── Stopwords FR + EN ─────────────────────────────────────────────────────────
const STOPWORDS = new Set([
'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se',
'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont',
'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur',
'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme',
'mais','donc','car','cet','cette','ces','leur','leurs','note','notes',
'the','a','an','and','or','but','in','on','at','to','for','of','with','by',
'from','is','are','was','were','be','been','have','has','had','do','does',
'did','will','would','could','should','may','might','this','that','these',
'those','it','its','they','them','their','he','she','we','you','not','no',
'so','if','as','up','out','about','also','just','can','all','any','get',
])
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ')
}
function extractKeywords(text: string): Set<string> {
return new Set(
stripHtml(text)
.toLowerCase()
.split(/[\s\p{P}]+/u)
.filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w))
)
}
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0
let intersection = 0
for (const w of a) if (b.has(w)) intersection++
return intersection / (a.size + b.size - intersection)
}
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard'
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
// GET /api/graph — connexions automatiques à 3 niveaux
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const { searchParams } = new URL(request.url)
const notebookId = searchParams.get('notebookId') || undefined
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) },
select: {
id: true, title: true, content: true, notebookId: true, createdAt: true,
labelRelations: { select: { id: true } },
notebook: { select: { id: true, name: true } },
},
})
if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] })
const ids = notes.map(n => n.id)
// Pré-calcul
const keywordsMap = new Map<string, Set<string>>()
const labelMap = new Map<string, Set<string>>()
for (const note of notes) {
keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`))
labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id)))
}
const edgeMap = new Map<string, GraphEdge>()
function upsertEdge(a: string, b: string, weight: number, type: EdgeType) {
const key = a < b ? `${a}--${b}` : `${b}--${a}`
const ex = edgeMap.get(key)
if (!ex || ex.weight < weight) edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
}
// ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ──────────
for (const noteA of notes) {
const title = (noteA.title ?? '').trim().toLowerCase()
if (title.length < 3) continue
const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`\\b${escaped}\\b`, 'i')
for (const noteB of notes) {
if (noteA.id === noteB.id) continue
if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention')
}
}
// ── Niveau 2 : Labels partagés ────────────────────────────────────────────
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const la = labelMap.get(ids[i])!
const lb = labelMap.get(ids[j])!
const shared = [...la].filter(l => lb.has(l)).length
if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label')
}
}
// ── Niveau 3 : Jaccard (désactivé > 500 notes) ───────────────────────────
if (notes.length <= 500) {
for (let i = 0; i < ids.length; i++) {
const kwI = keywordsMap.get(ids[i])!
const candidates: { j: number; score: number }[] = []
for (let j = i + 1; j < ids.length; j++) {
const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!)
if (score >= 0.12) candidates.push({ j, score })
}
candidates.sort((a, b) => b.score - a.score).slice(0, 10)
.forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard'))
}
}
const degreeMap = new Map<string, number>()
for (const e of edgeMap.values()) {
degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1)
degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1)
}
const nodes = notes.map(n => ({
id: n.id,
title: n.title || 'Sans titre',
notebookId: n.notebookId,
createdAt: n.createdAt,
degree: degreeMap.get(n.id) ?? 0,
}))
// Build clusters (notebooks)
const notebookMap = new Map<string, string>()
for (const n of notes) {
if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name)
}
const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name }))
return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters })
}

View File

@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
function extractWikilinks(content: string): { title: string; snippet: string }[] {
const plain = content.replace(/<[^>]+>/g, ' ')
const results: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
results.push({ title, snippet })
}
return results
}
/**
* POST /api/graph/sync-all
* Batch-sync [[wikilinks]] for ALL notes of the authenticated user.
* Call once to populate the NoteLink table from existing notes.
*/
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// Get all non-trashed notes with content
const notes = await prisma.note.findMany({
where: { userId, trashedAt: null, content: { not: '' } },
select: { id: true, content: true, notebookId: true },
})
let totalLinks = 0
for (const note of notes) {
if (!note.content.includes('[[')) continue
const wikilinks = extractWikilinks(note.content)
if (wikilinks.length === 0) continue
for (const { title, snippet } of wikilinks) {
let targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },
trashedAt: null,
},
select: { id: true },
})
if (!targetNote) {
// Skip stubs in batch sync — we only link existing notes
continue
}
if (targetNote.id === note.id) continue
try {
await (prisma as any).noteLink.upsert({
where: { sourceNoteId_targetNoteId: { sourceNoteId: note.id, targetNoteId: targetNote.id } },
update: { contextSnippet: snippet },
create: { sourceNoteId: note.id, targetNoteId: targetNote.id, contextSnippet: snippet },
})
totalLinks++
} catch {
// ignore duplicate constraint errors
}
}
}
return NextResponse.json({ synced: notes.length, links: totalLinks })
}

View File

@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { CreateLabelSchema } from '@/lib/validators'
const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
@@ -12,11 +14,10 @@ export async function GET(request: NextRequest) {
}
try {
const searchParams = request.nextUrl.searchParams
const notebookId = searchParams.get('notebookId')
const notebookId = request.nextUrl.searchParams.get('notebookId')
// Build where clause
const where: any = {}
// userId is always required — prevents reading another user's labels by notebookId
const where: Prisma.LabelWhereInput = { userId: session.user.id }
if (notebookId === 'null' || notebookId === '') {
// Get labels without a notebook (backward compatibility)
@@ -24,9 +25,6 @@ export async function GET(request: NextRequest) {
} else if (notebookId) {
// Get labels for a specific notebook
where.notebookId = notebookId
} else {
// Get all labels for the user
where.userId = session.user.id
}
const labels = await prisma.label.findMany({
@@ -61,21 +59,14 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, color, notebookId } = body
if (!name || typeof name !== 'string') {
const parsed = CreateLabelSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Label name is required' },
{ status: 400 }
)
}
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
{ success: false, error: 'notebookId is required' },
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { name, color, notebookId } = parsed.data
// Verify notebook ownership
const notebook = await prisma.notebook.findUnique({

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { PatchNotebookSchema } from '@/lib/validators'
async function getDescendantIds(notebookId: string): Promise<string[]> {
const ids: string[] = []
@@ -28,7 +30,14 @@ export async function PATCH(
try {
const { id } = await params
const body = await request.json()
const { name, icon, color, order, trashedAt, parentId } = body
const parsed = PatchNotebookSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { name, icon, color, order, trashedAt, parentId } = parsed.data
const existing = await prisma.notebook.findUnique({
where: { id },
@@ -76,12 +85,12 @@ export async function PATCH(
}
}
const updateData: any = {}
if (name !== undefined) updateData.name = name.trim()
const updateData: Prisma.NotebookUpdateInput = {}
if (name !== undefined) updateData.name = name
if (icon !== undefined) updateData.icon = icon
if (color !== undefined) updateData.color = color
if (order !== undefined) updateData.order = order
if (trashedAt !== undefined) updateData.trashedAt = trashedAt
if (trashedAt !== undefined) updateData.trashedAt = trashedAt ? new Date(trashedAt) : null
if (parentId !== undefined) updateData.parentId = parentId
if (trashedAt !== undefined) {

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
import { CreateNotebookSchema } from '@/lib/validators'
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
@@ -63,11 +65,14 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, icon, color, parentId } = body
if (!name || typeof name !== 'string') {
return NextResponse.json({ success: false, error: 'Notebook name is required' }, { status: 400 })
const parsed = CreateNotebookSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { name, icon, color, parentId } = parsed.data
if (parentId) {
const parent = await prisma.notebook.findFirst({
@@ -78,9 +83,7 @@ export async function POST(request: NextRequest) {
}
}
const whereClause: any = { userId: session.user.id }
if (parentId) whereClause.parentId = parentId
else whereClause.parentId = null
const whereClause: Prisma.NotebookWhereInput = { userId: session.user.id, parentId: parentId ?? null }
const highestOrder = await prisma.notebook.findFirst({
where: whereClause,

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
// GET /api/notes/[id]/backlinks
// Returns all notes that link TO this note via [[wikilinks]]
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Verify the note belongs to the user
const note = await prisma.note.findUnique({ where: { id }, select: { userId: true } })
if (!note || note.userId !== session.user.id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const backlinks = await (prisma as any).noteLink.findMany({
where: { targetNoteId: id },
include: {
sourceNote: {
select: { id: true, title: true, updatedAt: true, notebookId: true },
},
},
orderBy: { createdAt: 'desc' },
})
return NextResponse.json({
backlinks: backlinks.map((bl: any) => ({
id: bl.id,
sourceNote: bl.sourceNote,
contextSnippet: bl.contextSnippet,
createdAt: bl.createdAt,
})),
})
}

View File

@@ -167,6 +167,11 @@ export async function PUT(
console.error('[HISTORY] Failed to create snapshot from /api/notes/[id] PUT:', snapshotError)
}
// Fire-and-forget: sync [[wikilinks]] in background after content change
if ('content' in updateData) {
syncNoteLinksBackground(id, session.user.id, note.content).catch(() => {})
}
return NextResponse.json({
success: true,
data: parseNote(note)
@@ -180,6 +185,56 @@ export async function PUT(
}
}
/** Background job: parse [[wikilinks]] and sync NoteLink table */
async function syncNoteLinksBackground(noteId: string, userId: string, content: string) {
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
const plain = content.replace(/<[^>]+>/g, ' ')
const wikilinks: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
wikilinks.push({ title, snippet })
}
const upsertedIds: string[] = []
for (const { title, snippet } of wikilinks) {
let targetNote = await prisma.note.findFirst({
where: { userId, title: { equals: title, mode: 'insensitive' }, trashedAt: null },
select: { id: true },
})
if (!targetNote) {
targetNote = await prisma.note.create({
data: { title, content: '', userId, type: 'richtext', color: 'default', isMarkdown: true, order: 0 },
select: { id: true },
})
}
if (targetNote.id === noteId) continue
await (prisma as any).noteLink.upsert({
where: { sourceNoteId_targetNoteId: { sourceNoteId: noteId, targetNoteId: targetNote.id } },
update: { contextSnippet: snippet.slice(0, 200) },
create: { id: crypto.randomUUID(), sourceNoteId: noteId, targetNoteId: targetNote.id, contextSnippet: snippet.slice(0, 200) },
})
upsertedIds.push(targetNote.id)
}
if (upsertedIds.length > 0) {
await (prisma as any).noteLink.deleteMany({
where: { sourceNoteId: noteId, targetNoteId: { notIn: upsertedIds } },
})
} else {
await (prisma as any).noteLink.deleteMany({ where: { sourceNoteId: noteId } })
}
}
// DELETE /api/notes/[id] - Delete a note
export async function DELETE(
request: NextRequest,

View File

@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
const WIKILINK_RE = /\[\[([^\]|#]+?)(?:[|#][^\]]+)?\]\]/g
/**
* Extract [[wikilink]] targets from markdown/html content.
* Returns deduplicated list of linked note titles.
*/
function extractWikilinks(content: string): { title: string; snippet: string }[] {
// Strip HTML tags
const plain = content.replace(/<[^>]+>/g, ' ')
const results: { title: string; snippet: string }[] = []
const seen = new Set<string>()
let match: RegExpExecArray | null
WIKILINK_RE.lastIndex = 0
while ((match = WIKILINK_RE.exec(plain)) !== null) {
const title = match[1].trim()
if (!title || seen.has(title.toLowerCase())) continue
seen.add(title.toLowerCase())
// Extract snippet: 50 chars before + after the match
const start = Math.max(0, match.index - 50)
const end = Math.min(plain.length, match.index + match[0].length + 50)
const snippet = plain.slice(start, end).replace(/\s+/g, ' ').trim()
results.push({ title, snippet })
}
return results
}
// POST /api/notes/[id]/sync-links
// Parse [[wikilinks]] in note content and sync the NoteLink table.
// Called automatically after note save.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const userId = session.user.id
const note = await prisma.note.findUnique({
where: { id },
select: { id: true, userId: true, content: true },
})
if (!note || note.userId !== userId) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
const wikilinks = extractWikilinks(note.content)
// For each wikilink, find or create the target note
const upsertedLinks: string[] = []
for (const { title, snippet } of wikilinks) {
// Find target note by title (case-insensitive) belonging to same user
let targetNote = await prisma.note.findFirst({
where: {
userId,
title: { equals: title, mode: 'insensitive' },
trashedAt: null,
},
select: { id: true },
})
if (!targetNote) {
// Create a stub note so the link resolves
targetNote = await prisma.note.create({
data: {
title,
content: '',
userId,
type: 'richtext',
color: 'default',
isMarkdown: true,
order: 0,
},
select: { id: true },
})
}
// Skip self-links
if (targetNote.id === id) continue
// Upsert the NoteLink
await (prisma as any).noteLink.upsert({
where: {
sourceNoteId_targetNoteId: {
sourceNoteId: id,
targetNoteId: targetNote.id,
},
},
update: { contextSnippet: snippet.slice(0, 200) },
create: {
id: crypto.randomUUID(),
sourceNoteId: id,
targetNoteId: targetNote.id,
contextSnippet: snippet.slice(0, 200),
},
})
upsertedLinks.push(targetNote.id)
}
// Delete obsolete links (links that existed before but wikilink was removed)
if (upsertedLinks.length > 0) {
await (prisma as any).noteLink.deleteMany({
where: {
sourceNoteId: id,
targetNoteId: { notIn: upsertedLinks },
},
})
} else {
// No wikilinks at all — remove all outgoing links from this note
await (prisma as any).noteLink.deleteMany({
where: { sourceNoteId: id },
})
}
return NextResponse.json({ synced: upsertedLinks.length })
}

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { parseNote } from '@/lib/utils'
import { CreateNoteSchema, UpdateNoteSchema, GetNotesQuerySchema } from '@/lib/validators'
// GET /api/notes - Get all notes
export async function GET(request: NextRequest) {
@@ -14,13 +16,23 @@ export async function GET(request: NextRequest) {
}
try {
const searchParams = request.nextUrl.searchParams
const includeArchived = searchParams.get('archived') === 'true'
const search = searchParams.get('search')
const notebookId = searchParams.get('notebookId')
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : undefined
const rawQuery = {
archived: request.nextUrl.searchParams.get('archived') ?? undefined,
search: request.nextUrl.searchParams.get('search') ?? undefined,
notebookId: request.nextUrl.searchParams.get('notebookId') ?? undefined,
limit: request.nextUrl.searchParams.get('limit') ?? undefined,
}
const queryResult = GetNotesQuerySchema.safeParse(rawQuery)
if (!queryResult.success) {
return NextResponse.json(
{ success: false, error: 'Invalid query parameters', details: queryResult.error.flatten() },
{ status: 400 }
)
}
const { archived, search, notebookId, limit } = queryResult.data
const includeArchived = archived === 'true'
const where: any = {
const where: Prisma.NoteWhereInput = {
userId: session.user.id,
trashedAt: null
}
@@ -75,25 +87,25 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { title, content, color, type, checkItems, labels, images } = body
if (!content && type !== 'checklist') {
const parsed = CreateNoteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Content is required' },
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { title, content, color, type, checkItems, labels, images } = parsed.data
const note = await prisma.note.create({
data: {
userId: session.user.id,
title: title || null,
content: content || '',
color: color || 'default',
type: type || 'text',
checkItems: checkItems ?? null,
labels: labels ?? null,
images: images ?? null,
title: title ?? null,
content: content ?? '',
color: color ?? 'default',
type: type ?? 'text',
checkItems: checkItems != null ? JSON.stringify(checkItems) : null,
labels: labels != null ? JSON.stringify(labels) : null,
images: images != null ? JSON.stringify(images) : null,
}
})
@@ -122,34 +134,16 @@ export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
if (!id) {
const parsed = UpdateNoteSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ success: false, error: 'Note ID is required' },
{ success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = parsed.data
const existingNote = await prisma.note.findUnique({
where: { id }
})
if (!existingNote) {
return NextResponse.json(
{ success: false, error: 'Note not found' },
{ status: 404 }
)
}
if (existingNote.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Forbidden' },
{ status: 403 }
)
}
const updateData: any = {}
const updateData: Prisma.NoteUpdateInput = {}
if (title !== undefined) updateData.title = title
if (content !== undefined) updateData.content = content
@@ -161,10 +155,21 @@ export async function PUT(request: NextRequest) {
if (isArchived !== undefined) updateData.isArchived = isArchived
if (images !== undefined) updateData.images = images ?? null
const note = await prisma.note.update({
where: { id },
data: updateData
})
let note
try {
note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData
})
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
return NextResponse.json(
{ success: false, error: 'Note not found or access denied' },
{ status: 404 }
)
}
throw e
}
return NextResponse.json({
success: true,
@@ -200,29 +205,21 @@ export async function DELETE(request: NextRequest) {
)
}
const existingNote = await prisma.note.findUnique({
where: { id }
})
if (!existingNote) {
return NextResponse.json(
{ success: false, error: 'Note not found' },
{ status: 404 }
)
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
})
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
return NextResponse.json(
{ success: false, error: 'Note not found or access denied' },
{ status: 404 }
)
}
throw e
}
if (existingNote.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Forbidden' },
{ status: 403 }
)
}
await prisma.note.update({
where: { id },
data: { trashedAt: new Date() }
})
return NextResponse.json({
success: true,
message: 'Note moved to trash'

View File

@@ -310,7 +310,7 @@ export function AgentDetailView({
animate={{ opacity: 1, y: 0 }}
className="min-h-full flex flex-col"
>
<header className="px-12 py-10 border-b border-border bg-background/80 backdrop-blur-md sticky top-0 z-30">
<header className="px-4 sm:px-8 md:px-12 py-5 sm:py-8 md:py-10 border-b border-border bg-background/80 backdrop-blur-md sticky top-0 z-30">
<div className="flex items-center justify-between max-w-5xl mx-auto">
<button
onClick={onBack}
@@ -348,7 +348,7 @@ export function AgentDetailView({
</div>
</header>
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-16">
<div className="flex-1 px-4 sm:px-8 md:px-12 py-8 sm:py-12 md:py-16 max-w-5xl mx-auto w-full space-y-8 sm:space-y-12 md:space-y-16">
<section className="space-y-8">
<div className="flex items-end justify-between">
<div className="space-y-4">

View File

@@ -279,7 +279,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
<MessageSquare size={18} />
</div>
<p className="text-[11px] italic leading-relaxed px-12">{t('ai.welcomeMsg')}</p>
<p className="text-[11px] italic leading-relaxed px-6">{t('ai.welcomeMsg')}</p>
</div>
)}

View File

@@ -19,6 +19,13 @@ import {
Users,
Globe,
Lock,
Menu,
Star,
Sparkles,
List,
LayoutGrid,
X,
Edit2,
} from 'lucide-react'
import dynamic from 'next/dynamic'
import { useLanguage } from '@/lib/i18n'
@@ -36,6 +43,9 @@ import {
useAddManualIdea,
useBrainstormActivity,
useUpdateBrainstormSettings,
useStarIdea,
useSummarizeBrainstorm,
useRenameBrainstormSession,
} from '@/hooks/use-brainstorm'
import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket'
import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors'
@@ -106,12 +116,20 @@ export function BrainstormPage() {
const joinMutation = useJoinBrainstorm()
const addManualIdea = useAddManualIdea(activeSessionId || '')
const { data: activities } = useBrainstormActivity(activeSessionId)
const starIdea = useStarIdea(activeSessionId || '')
const summarize = useSummarizeBrainstorm(activeSessionId || '')
const renameSession = useRenameBrainstormSession(activeSessionId || '')
const [impactToast, setImpactToast] = useState<{ notesEnriched: number; notesMarkedDry: number } | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const [exportToast, setExportToast] = useState<{ noteTitle: string; notebookName: string } | null>(null)
const [convertToast, setConvertToast] = useState<{ noteTitle: string; noteId: string } | null>(null)
const [remoteMove, setRemoteMove] = useState<{ ideaId: string; x: number; y: number; _seq: number } | null>(null)
const [playbackIdeas, setPlaybackIdeas] = useState<any[] | null>(null)
const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas')
const [summaryOpen, setSummaryOpen] = useState(false)
const [summaryText, setSummaryText] = useState<string | null>(null)
const [renamingSession, setRenamingSession] = useState<string | null>(null)
const [renameInput, setRenameInput] = useState('')
const canvasContainerRef = useRef<HTMLDivElement>(null)
const moveSeq = useRef(0)
@@ -260,11 +278,35 @@ export function BrainstormPage() {
addManualIdea.mutate({ title, parentIdeaId, locale: language })
}, [addManualIdea, language])
const handleStarIdea = async (ideaId: string) => {
try { await starIdea.mutateAsync(ideaId) } catch {}
}
const handleSummarize = async () => {
setSummaryOpen(true)
if (summaryText) return
try {
const text = await summarize.mutateAsync(language)
setSummaryText(text)
} catch { setSummaryText(null) }
}
const handleRenameSession = async (sessionId: string) => {
if (!renameInput.trim() || renameInput.trim() === session?.seedIdea) {
setRenamingSession(null)
return
}
try {
await renameSession.mutateAsync(renameInput.trim())
} catch {}
setRenamingSession(null)
}
const isGenerating = createBrainstorm.isPending || expandIdea.isPending
return (
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-black/20 z-10 relative overflow-hidden">
<div className="p-4 sm:p-8 md:p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-black/20 z-10 relative overflow-hidden">
<div
className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
style={{
@@ -275,7 +317,15 @@ export function BrainstormPage() {
/>
<div className="max-w-4xl mx-auto relative">
<div className="flex items-center gap-5 mb-8">
{/* Hamburger mobile */}
<button
className="md:hidden absolute -top-1 -start-1 p-2 text-foreground/70 hover:bg-foreground/5 rounded-lg transition-colors"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label="Ouvrir la navigation"
>
<Menu size={20} />
</button>
<div className="flex items-center gap-3 sm:gap-5 mb-4 sm:mb-8 ps-9 md:ps-0">
<motion.div
animate={{ rotate: isGenerating ? 360 : 0 }}
transition={{
@@ -283,12 +333,12 @@ export function BrainstormPage() {
duration: 20,
ease: 'linear',
}}
className="w-14 h-14 rounded-2xl bg-brand-accent shadow-[0_0_20px_rgba(164,113,72,0.2)] flex items-center justify-center text-white"
className="w-10 h-10 sm:w-14 sm:h-14 rounded-xl sm:rounded-2xl bg-brand-accent shadow-[0_0_20px_rgba(164,113,72,0.2)] flex items-center justify-center text-white shrink-0"
>
<Wind size={28} />
</motion.div>
<div className="flex-1">
<h1 className="text-4xl font-serif font-medium text-foreground tracking-tight">
<h1 className="text-2xl sm:text-4xl font-serif font-medium text-foreground tracking-tight">
{t('brainstorm.title')}
</h1>
<div className="flex items-center gap-2 mt-1">
@@ -302,8 +352,8 @@ export function BrainstormPage() {
{session && !isGuest && (
<div className="flex items-center gap-3">
<button
onClick={handleExport}
disabled={exportBrainstorm.isPending}
onClick={handleSummarize}
disabled={summarize.isPending}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-brand-accent transition-all shadow-sm disabled:opacity-50"
title={t('brainstorm.export') || 'Export'}
>
@@ -375,7 +425,7 @@ export function BrainstormPage() {
onChange={(e) => setSeedInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm()}
placeholder={t('brainstorm.placeholder') || 'Enter a concept to unfold...'}
className="w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-foreground shadow-sm group-hover:shadow-md border-border/40 focus:border-brand-accent/40 focus:ring-4 focus:ring-brand-accent/5"
className="w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-4 sm:px-8 py-4 sm:py-7 pr-16 sm:pr-20 outline-none transition-all text-lg sm:text-2xl font-serif italic text-foreground shadow-sm group-hover:shadow-md border-border/40 focus:border-brand-accent/40 focus:ring-4 focus:ring-brand-accent/5"
/>
<button
onClick={() => handleStartBrainstorm()}
@@ -413,6 +463,52 @@ export function BrainstormPage() {
</motion.div>
)}
</AnimatePresence>
{/* Seed prompts — shown only when no session and not generating */}
{!session && !createBrainstorm.isPending && !activeSessionId && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="mt-5 flex flex-wrap gap-2"
>
{[
t('brainstorm.exampleSeed1') || 'Simplify my morning routine',
t('brainstorm.exampleSeed2') || 'Ideas for a creative project this weekend',
t('brainstorm.exampleSeed3') || 'Systems to manage my energy better',
t('brainstorm.exampleSeed4') || 'What I learned this week',
].map((example) => (
<button
key={example}
onClick={() => handleStartBrainstorm(example)}
className="px-4 py-2 rounded-full border border-border bg-white dark:bg-white/5 text-sm text-muted-foreground hover:text-foreground hover:border-brand-accent/40 hover:bg-brand-accent/5 transition-all"
>
{example}
</button>
))}
</motion.div>
)}
{/* View mode toggle — shown on mobile only when a session is active */}
{session && (
<div className="mt-4 md:hidden flex items-center gap-2">
<button
onClick={() => setViewMode('canvas')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-widest transition-all ${viewMode === 'canvas' ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-foreground/5'}`}
>
<LayoutGrid size={12} /> {t('brainstorm.viewCanvas') || 'Canvas'}
</button>
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-widest transition-all ${viewMode === 'list' ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-foreground/5'}`}
>
<List size={12} /> {t('brainstorm.viewList') || 'List'}
</button>
<span className="text-[10px] text-muted-foreground ms-auto">
{session.ideas.filter(i => i.status !== 'dismissed').length} {t('brainstorm.ideasCount') || 'ideas'}
</span>
</div>
)}
</div>
</div>
@@ -429,6 +525,48 @@ export function BrainstormPage() {
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-foreground/20 border-t-foreground rounded-full animate-spin" />
</div>
) : session && viewMode === 'list' ? (
/* Mobile list view */
<div className="h-full overflow-y-auto p-4 space-y-6">
{[1, 2, 3].map(wave => {
const waveIdeas = session.ideas.filter(i => i.waveNumber === wave && i.status !== 'dismissed')
if (waveIdeas.length === 0) return null
return (
<div key={wave}>
<div className="flex items-center gap-2 mb-3">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: wave === 1 ? '#fb923c' : wave === 2 ? '#60a5fa' : '#a78bfa' }}
/>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t('brainstorm.waveBadge', { wave })} {waveIdeas.length}
</span>
</div>
<div className="space-y-2">
{waveIdeas.map(idea => (
<button
key={idea.id}
onClick={() => { setSelectedIdeaId(idea.id) }}
className={`w-full text-start p-4 rounded-xl border transition-all ${selectedIdeaId === idea.id ? 'border-brand-accent/40 bg-brand-accent/5' : 'border-border bg-white dark:bg-white/5 hover:border-foreground/20'}`}
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground text-sm leading-snug">{idea.title}</p>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{idea.description}</p>
</div>
<div className="shrink-0 flex items-center gap-1.5">
{idea.isStarred && <Star size={12} className="text-amber-400 fill-amber-400" />}
{idea.status === 'converted' && <Check size={12} className="text-emerald-500" />}
<ChevronRight size={14} className="text-muted-foreground/40" />
</div>
</div>
</button>
))}
</div>
</div>
)
})}
</div>
) : session ? (
<WaveCanvas
session={session}
@@ -507,11 +645,16 @@ export function BrainstormPage() {
<AnimatePresence>
{selectedIdea && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
className="w-[400px] border-l border-border bg-white dark:bg-[#1A1A1A] flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
initial={{ y: '100%', x: 0 }}
animate={{ y: 0, x: 0 }}
exit={{ y: '100%', x: 0 }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed bottom-0 start-0 end-0 max-h-[80vh] z-30 md:relative md:bottom-auto md:start-auto md:end-auto md:max-h-none md:static md:w-[400px] border-t md:border-t-0 md:border-l border-border bg-white dark:bg-[#1A1A1A] flex flex-col rounded-t-2xl md:rounded-none shadow-[0_-20px_40px_rgba(0,0,0,0.08)] md:shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
>
{/* Drag handle for mobile */}
<div className="md:hidden flex justify-center pt-3 pb-1 shrink-0">
<div className="w-10 h-1 rounded-full bg-border" />
</div>
<div className="p-8 flex-1 overflow-y-auto">
<div className="flex items-center justify-between mb-8">
<div
@@ -531,10 +674,20 @@ export function BrainstormPage() {
{t('brainstorm.noteCreated') || 'Note Created'}
</span>
)}
{canEdit && (
<button
onClick={() => setSelectedIdeaId(null)}
className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground"
onClick={() => handleStarIdea(selectedIdea.id)}
disabled={starIdea.isPending}
className={`p-2 rounded-full transition-colors ${selectedIdea.isStarred ? 'text-amber-400 hover:text-amber-500' : 'text-muted-foreground hover:text-amber-400'}`}
title={selectedIdea.isStarred ? (t('brainstorm.unstar') || 'Unstar') : (t('brainstorm.star') || 'Star')}
>
<Star size={18} className={selectedIdea.isStarred ? 'fill-amber-400' : ''} />
</button>
)}
<button
onClick={() => setSelectedIdeaId(null)}
className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground"
>
<ChevronRight size={20} />
</button>
</div>
@@ -699,20 +852,30 @@ export function BrainstormPage() {
<div className="w-px flex-1 bg-border/40" />
<div className="flex flex-col gap-3 overflow-y-auto px-2">
{sessions?.map((s) => (
<button
key={s.id}
onClick={() => setActiveSessionId(s.id)}
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
activeSessionId === s.id
? 'bg-foreground text-background scale-110 shadow-lg'
: (s as any)._owned === false
? 'bg-brand-accent dark:bg-brand-accent/10 text-brand-accent hover:bg-blue-100 hover:text-brand-accent'
: 'bg-white dark:bg-white/10 text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
}`}
title={s.seedIdea}
>
{s.seedIdea.charAt(0).toUpperCase()}
</button>
<div key={s.id} className="relative group/session">
<button
onClick={() => setActiveSessionId(s.id)}
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
activeSessionId === s.id
? 'bg-foreground text-background scale-110 shadow-lg'
: (s as any)._owned === false
? 'bg-brand-accent dark:bg-brand-accent/10 text-brand-accent hover:bg-blue-100 hover:text-brand-accent'
: 'bg-white dark:bg-white/10 text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
}`}
title={s.seedIdea}
>
{s.seedIdea.charAt(0).toUpperCase()}
</button>
{activeSessionId === s.id && (
<button
onClick={() => { setRenamingSession(s.id); setRenameInput(s.seedIdea) }}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-foreground/10 hover:bg-foreground/20 flex items-center justify-center opacity-0 group-hover/session:opacity-100 transition-opacity"
title={t('brainstorm.renameSession') || 'Rename'}
>
<Edit2 size={8} className="text-foreground" />
</button>
)}
</div>
))}
</div>
<div className="w-px h-12 bg-border/40" />
@@ -730,6 +893,105 @@ export function BrainstormPage() {
/>
)}
{/* AI Summary modal */}
<AnimatePresence>
{summaryOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
onClick={() => setSummaryOpen(false)}
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 40, opacity: 0 }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-lg bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl p-8 relative"
>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Sparkles size={20} className="text-brand-accent" />
<h3 className="font-serif text-xl font-medium">{t('brainstorm.exportModalTitle') || 'Session summary'}</h3>
</div>
<button onClick={() => setSummaryOpen(false)} className="p-1.5 hover:bg-foreground/5 rounded-full text-muted-foreground">
<X size={16} />
</button>
</div>
{summarize.isPending ? (
<div className="flex items-center gap-3 text-muted-foreground italic py-8 justify-center">
<div className="flex gap-1.5">
{[0, 1, 2].map(i => (
<motion.div key={i} animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }} transition={{ duration: 1.5, repeat: Infinity, delay: i * 0.2 }} className="w-1.5 h-1.5 rounded-full bg-brand-accent" />
))}
</div>
<span>{t('brainstorm.generating') || 'Generating synthesis...'}</span>
</div>
) : summaryText ? (
<p className="text-foreground/80 leading-relaxed font-light text-base">{summaryText}</p>
) : (
<p className="text-muted-foreground italic text-sm text-center py-6">{t('brainstorm.summaryError') || 'Could not generate summary'}</p>
)}
<div className="mt-6 flex items-center gap-3 justify-between">
{summaryText && (
<button
onClick={() => { setSummaryText(null); handleSummarize() }}
className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
>
{t('brainstorm.regenerateSummary') || 'Regenerate'}
</button>
)}
<button
onClick={() => { setSummaryOpen(false); handleExport() }}
disabled={exportBrainstorm.isPending || summarize.isPending}
className="ms-auto flex items-center gap-2 px-5 py-2.5 bg-foreground text-background rounded-xl text-xs font-bold uppercase tracking-widest disabled:opacity-50 hover:opacity-80 transition-opacity"
>
<Download size={13} />
{t('brainstorm.exportAsNote') || 'Export as note'}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Rename session modal */}
<AnimatePresence>
{renamingSession && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
onClick={() => setRenamingSession(null)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl p-6"
>
<h3 className="font-serif text-lg font-medium mb-4">{t('brainstorm.renameSession') || 'Rename session'}</h3>
<input
autoFocus
type="text"
value={renameInput}
onChange={e => setRenameInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameSession(renamingSession!); if (e.key === 'Escape') setRenamingSession(null) }}
className="w-full border border-border rounded-xl px-4 py-3 text-sm bg-transparent outline-none focus:border-brand-accent/40 focus:ring-2 focus:ring-brand-accent/10"
/>
<div className="flex gap-2 mt-4 justify-end">
<button onClick={() => setRenamingSession(null)} className="px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors">{t('common.cancel') || 'Cancel'}</button>
<button onClick={() => handleRenameSession(renamingSession!)} disabled={renameSession.isPending} className="px-4 py-2 text-sm bg-foreground text-background rounded-xl disabled:opacity-50">{t('common.save') || 'Save'}</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{impactToast && (
<motion.div

View File

@@ -50,6 +50,7 @@ import { getNotebookIcon } from '@/lib/notebook-icon'
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
import { scrapePageText } from '@/app/actions/scrape'
import { PersonasPanel } from '@/components/personas-panel'
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -210,11 +211,13 @@ export function ContextualAIChat({
// Generate slides / diagram state
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
const [generateProgress, setGenerateProgress] = useState(0)
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
const [customLangInput, setCustomLangInput] = useState('')
// Generation options
const [slideTheme, setSlideTheme] = useState('architectural_mono')
const [slideTheme, setSlideTheme] = useState('auto')
const [slideStyle, setSlideStyle] = useState('professional')
const [slideTemplate, setSlideTemplate] = useState('auto')
const [diagramType, setDiagramType] = useState('logic_flow')
const [diagramStyle, setDiagramStyle] = useState('polished')
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
@@ -358,6 +361,24 @@ export function ContextualAIChat({
// ── Generate slides / diagram ────────────────────────────────────────────────
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const generateProgressRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Fake progress bar: fast up to 30%, then slows, caps at 90% until done
const startProgressBar = () => {
setGenerateProgress(0)
let current = 0
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
generateProgressRef.current = setInterval(() => {
current += current < 30 ? 3 : current < 60 ? 1.2 : current < 80 ? 0.4 : 0.1
if (current >= 90) { clearInterval(generateProgressRef.current!); current = 90 }
setGenerateProgress(Math.min(current, 90))
}, 300)
}
const finishProgressBar = () => {
if (generateProgressRef.current) clearInterval(generateProgressRef.current)
setGenerateProgress(100)
setTimeout(() => setGenerateProgress(0), 600)
}
const handleGenerate = async (type: 'slides' | 'diagram') => {
if (!noteId) {
@@ -366,6 +387,7 @@ export function ContextualAIChat({
}
setGenerateLoading(type)
setGenerateResult(null)
startProgressBar()
const toastId = mToast.loading(
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
@@ -382,6 +404,7 @@ export function ContextualAIChat({
theme: type === 'slides' ? slideTheme : diagramType,
style: type === 'slides' ? slideStyle : diagramStyle,
language: language === 'fr' ? 'French' : 'English',
template: type === 'slides' ? slideTemplate : undefined,
}),
})
const data = await res.json()
@@ -394,7 +417,18 @@ export function ContextualAIChat({
const { agentId } = data as { agentId: string }
if (generatePollRef.current) clearInterval(generatePollRef.current)
let pollCount = 0
const MAX_POLLS = 200 // 200 × 3s = 10 min safety timeout
generatePollRef.current = setInterval(async () => {
pollCount++
if (pollCount > MAX_POLLS) {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
finishProgressBar()
setGenerateLoading(null)
mToast.error(t('ai.errorShort'), { id: toastId })
return
}
try {
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
const poll = await pollRes.json()
@@ -402,12 +436,14 @@ export function ContextualAIChat({
if (poll.status === 'success') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
finishProgressBar()
setGenerateLoading(null)
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
mToast.success(t('ai.readyToast'), { id: toastId })
} else if (poll.status === 'failure') {
clearInterval(generatePollRef.current!)
generatePollRef.current = null
finishProgressBar()
setGenerateLoading(null)
mToast.error(poll.error || t('ai.errorShort'), { id: toastId })
}
@@ -415,6 +451,7 @@ export function ContextualAIChat({
}, 3000)
} catch {
mToast.error(t('ai.errorShort'), { id: toastId })
finishProgressBar()
setGenerateLoading(null)
}
}
@@ -731,7 +768,7 @@ export function ContextualAIChat({
<div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
<MessageSquare size={18} />
</div>
<p className="text-[11px] italic leading-relaxed px-12">{t('ai.welcomeMsg')}</p>
<p className="text-[11px] italic leading-relaxed px-6">{t('ai.welcomeMsg')}</p>
</div>
)}
@@ -980,9 +1017,20 @@ export function ContextualAIChat({
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.theme')}</span>
<select value={slideTheme} onChange={e => setSlideTheme(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="architectural_mono">{t('ai.generate.themeArchitecturalMono')}</option>
<option value="vibrant_tech">{t('ai.generate.themeVibrantTech')}</option>
<option value="minimal_silk">{t('ai.generate.themeMinimalSilk')}</option>
<option value="auto">{t('ai.generate.themeAuto')}</option>
<option value="Architectural SaaS">Architectural SaaS SaaS, Produit</option>
<option value="Midnight Cathedral">Midnight Cathedral Finance, Luxe</option>
<option value="Aurora Borealis">Aurora Borealis Tech, IA</option>
<option value="Tokyo Neon">Tokyo Neon Gaming</option>
<option value="Sunlit Gallery">Sunlit Gallery Art, Culture</option>
<option value="Clinical Precision">Clinical Precision Santé, Science</option>
<option value="Venture Pitch">Venture Pitch Startup</option>
<option value="Forest Floor">Forest Floor ESG, Nature</option>
<option value="Steel & Glass">Steel &amp; Glass Architecture</option>
<option value="Cyberpunk Terminal">Cyberpunk Terminal Dev, Cyber</option>
<option value="Editorial Ink">Editorial Ink Journalisme</option>
<option value="Coastal Morning">Coastal Morning Éducation</option>
<option value="Paper Studio">Paper Studio Research</option>
</select>
</div>
<div className="space-y-1.5">
@@ -994,10 +1042,36 @@ export function ContextualAIChat({
</select>
</div>
</div>
<div className="space-y-1.5">
<span className="text-[8px] uppercase tracking-[0.2em] font-bold text-foreground/40 px-1">{t('ai.generate.template')}</span>
<select value={slideTemplate} onChange={e => setSlideTemplate(e.target.value)} className="w-full bg-card/60 border border-border rounded-lg px-2 py-2 text-[10px] outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground">
<option value="auto">{t('ai.generate.templateAuto')}</option>
<option value="board-update">{t('ai.generate.templateBoard')}</option>
<option value="project-status">{t('ai.generate.templateProject')}</option>
<option value="strategy-review">{t('ai.generate.templateStrategy')}</option>
<option value="quarterly-results">{t('ai.generate.templateQuarterly')}</option>
</select>
</div>
<button onClick={() => handleGenerate('slides')} disabled={!!generateLoading} className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em] disabled:opacity-50">
{generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
</button>
{/* Progress bar — visible only during slides generation */}
{generateLoading === 'slides' && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-[9px] text-foreground/40">
<span className="uppercase tracking-widest"> Génération en cours</span>
<span className="font-mono tabular-nums">{Math.round(generateProgress)}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-foreground/10 overflow-hidden">
<div
className="h-full rounded-full bg-brand-accent transition-all duration-300 ease-out"
style={{ width: `${generateProgress}%` }}
/>
</div>
</div>
)}
{generateResult?.type === 'slides' && generateResult.canvasId && (
<motion.div
initial={{ opacity: 0, y: 10 }}
@@ -1018,35 +1092,15 @@ export function ContextualAIChat({
<ExternalLink size={12} />
</a>
</div>
<button
onClick={async () => {
try {
const res = await fetch(`/api/canvas?id=${generateResult.canvasId}`)
const data = await res.json()
if (!data.canvas?.data) throw new Error('No data')
const parsed = JSON.parse(data.canvas.data)
if (!parsed.base64) throw new Error('No base64')
const byteChars = atob(parsed.base64)
const bytes = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
const blob = new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = parsed.filename || `${data.canvas.name || 'presentation'}.pptx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch {
mToast.error(t('ai.downloadFailedToast'))
}
}}
<a
href={`/lab?id=${generateResult.canvasId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-2.5 bg-brand-accent text-white rounded-lg text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity shadow-sm"
>
<Download size={13} />
{t('ai.pptxDownloadButton')}
</button>
<Presentation size={13} />
{t('ai.viewSlidesButton')}
</a>
</motion.div>
)}
@@ -1129,6 +1183,8 @@ export function ContextualAIChat({
</div>
</div>
{/* ── Personas IA ── */}
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
</motion.div>
)}

View File

@@ -10,7 +10,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X, Menu } from 'lucide-react'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh'
import { useReminderCheck } from '@/hooks/use-reminder-check'
@@ -81,7 +81,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [showSortMenu, setShowSortMenu] = useState(false)
const [showInlineSearch, setShowInlineSearch] = useState(false)
const [inlineSearchQuery, setInlineSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const inlineSearchRef = useRef<HTMLInputElement>(null)
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const notesRef = useRef(notes)
notesRef.current = notes
const { refreshKey, triggerRefresh } = useNoteRefresh()
@@ -98,13 +100,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
const [tagSearchQuery, setTagSearchQuery] = useState('')
useEffect(() => {
// Auto-trigger disabled — user opens manually from AI panel
// if (shouldSuggestLabels && suggestNotebookId) {
// setAutoLabelOpen(true)
// }
}, [shouldSuggestLabels, suggestNotebookId])
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
useEffect(() => {
if (searchParams.get('forceList') === '1') {
@@ -473,10 +468,18 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
) : (
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div
className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
className="px-4 sm:px-8 md:px-12 pt-6 sm:pt-10 md:pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
>
<div className="flex justify-between items-start">
<div>
<div className="flex justify-between items-start gap-3">
{/* Hamburger mobile — ouvre la sidebar */}
<button
className="md:hidden p-2 -ms-2 text-foreground hover:bg-foreground/5 rounded-lg transition-colors shrink-0 self-start mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label="Open menu"
>
<Menu size={22} />
</button>
<div className="flex-1 min-w-0">
{currentNotebook && notebookPath.length > 0 && (
<div
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60"
@@ -491,7 +494,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
))}
</div>
)}
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground leading-tight pe-12">
<h1 className="font-memento-serif text-2xl sm:text-3xl md:text-4xl font-medium tracking-tight text-foreground leading-tight pe-4 sm:pe-12">
{currentNotebook
? currentNotebook.name
: searchParams.get('shared') === '1'
@@ -527,7 +530,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
{/* Inline search — toggles an input within the toolbar */}
{showInlineSearch ? (
<div className="flex items-center gap-2">
<Search size={14} className="text-muted-foreground shrink-0" />
{isSearching ? (
<div className="w-3.5 h-3.5 border border-muted-foreground/50 border-t-foreground rounded-full animate-spin shrink-0" />
) : (
<Search size={14} className="text-muted-foreground shrink-0" />
)}
<input
ref={inlineSearchRef}
autoFocus
@@ -536,13 +543,18 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onChange={e => {
const q = e.target.value
setInlineSearchQuery(q)
const params = new URLSearchParams(searchParams.toString())
if (q.trim()) {
params.set('search', q)
} else {
params.delete('search')
}
router.push(`/home?${params.toString()}`)
setIsSearching(true)
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
searchDebounceRef.current = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString())
if (q.trim()) {
params.set('search', q)
} else {
params.delete('search')
}
router.push(`/home?${params.toString()}`)
setIsSearching(false)
}, 300)
}}
onBlur={() => {
if (!inlineSearchQuery) {
@@ -551,28 +563,32 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}}
onKeyDown={e => {
if (e.key === 'Escape') {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
setShowInlineSearch(false)
setInlineSearchQuery('')
setIsSearching(false)
const params = new URLSearchParams(searchParams.toString())
params.delete('search')
router.push(`/home?${params.toString()}`)
}
}}
placeholder={t('search.placeholder')}
className="w-48 bg-transparent border-b border-foreground/20 focus:border-foreground outline-none text-[13px] text-foreground placeholder:text-muted-foreground/50 py-0.5 transition-colors"
className="w-36 sm:w-48 bg-transparent border-b border-foreground/20 focus:border-foreground outline-none text-[13px] text-foreground placeholder:text-muted-foreground/50 py-0.5 transition-colors"
/>
{inlineSearchQuery && (
<button
onClick={() => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
setShowInlineSearch(false)
setInlineSearchQuery('')
setIsSearching(false)
const params = new URLSearchParams(searchParams.toString())
params.delete('search')
router.push(`/home?${params.toString()}`)
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<span className="text-[11px]">×</span>
<X size={12} />
</button>
)}
</div>
@@ -714,7 +730,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)}
</div>
<div className="px-12 flex-1 pb-20">
<div className="px-4 sm:px-8 md:px-12 flex-1 pb-10 sm:pb-16 md:pb-20">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : (
@@ -760,7 +776,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)}
</div>
<footer className="px-12 py-6 border-t border-foreground/5 text-center mt-auto">
<footer className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 border-t border-foreground/5 text-center mt-auto">
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-medium">
Memento &mdash; {new Date().getFullYear()}
</p>

View File

@@ -4,17 +4,23 @@ import dynamic from 'next/dynamic'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Download, Presentation } from 'lucide-react'
import { Download, Presentation, ExternalLink, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation'
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false }
)
const SlidesRenderer = dynamic(
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides</div> }
)
interface CanvasBoardProps {
initialData?: string
canvasId?: string
@@ -22,32 +28,37 @@ interface CanvasBoardProps {
}
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
type SlidesPayload = { type: 'slides'; html: string; title: string; theme?: string; style?: string; slideCount?: number; spec?: PresentationSpec }
function parseCanvasScene(initialData?: string): {
slides: SlidesPayload | null
pptx: PptxPayload | null
elements: readonly ExcalidrawElement[]
files: BinaryFiles
} {
if (!initialData) {
return { pptx: null, elements: [], files: {} }
return { slides: null, pptx: null, elements: [], files: {} }
}
try {
const parsed = JSON.parse(initialData)
if (parsed && parsed.type === 'slides' && parsed.html) {
return { slides: parsed as SlidesPayload, pptx: null, elements: [], files: {} }
}
if (parsed && parsed.type === 'pptx' && parsed.base64) {
return { pptx: parsed as PptxPayload, elements: [], files: {} }
return { slides: null, pptx: parsed as PptxPayload, elements: [], files: {} }
}
if (parsed && Array.isArray(parsed)) {
return { pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
return { slides: null, pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
}
if (parsed && parsed.elements) {
const files: BinaryFiles =
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
return { pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
return { slides: null, pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
}
} catch (e) {
console.error('[CanvasBoard] Failed to parse canvas data:', e)
}
return { pptx: null, elements: [], files: {} }
return { slides: null, pptx: null, elements: [], files: {} }
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
@@ -105,6 +116,153 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
)
}
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
const totalSlides = data.slideCount || 1
// Hide the global AI floating button while slides are displayed
useEffect(() => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
return () => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
}
}, [])
// Listen for slide-change events from the iframe via postMessage
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.type === 'slideChange' && typeof e.data.current === 'number') {
setCurrentSlide(e.data.current)
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [])
const navigate = (dir: number) => {
const iframe = iframeRef.current
if (!iframe) return
// Direct contentWindow access (works with allow-same-origin)
try {
const win = iframe.contentWindow as any
if (typeof win?.changeSlide === 'function') {
win.changeSlide(dir)
return
}
} catch (_) {}
// Fallback: postMessage
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
}
const openFullscreen = () => {
const blob = new Blob([data.html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
setTimeout(() => URL.revokeObjectURL(url), 10000)
}
const downloadHtml = () => {
const blob = new Blob([data.html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'presentation'}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 5000)
}
return (
<div className="absolute inset-0 flex flex-col bg-zinc-950">
{/* Toolbar */}
<div className="shrink-0 h-11 flex items-center justify-between px-4 bg-zinc-900/80 border-b border-white/10 backdrop-blur-sm z-10">
<div className="flex items-center gap-2.5 text-white/70 min-w-0">
<Presentation className="w-4 h-4 shrink-0 text-white/50" />
<span className="text-sm font-medium truncate">{name}</span>
{totalSlides > 1 && (
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
{currentSlide} / {totalSlides}
</span>
)}
</div>
<div className="flex items-center gap-2">
{canvasId && (
<a
href={`/api/canvas/slides/pptx?id=${canvasId}`}
download
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Exporter en PowerPoint"
>
<Download className="w-3.5 h-3.5" />
Export PPTX
</a>
)}
<button
onClick={downloadHtml}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Télécharger le HTML"
>
<Download className="w-3.5 h-3.5" />
Export HTML
</button>
<button
onClick={openFullscreen}
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
title="Ouvrir en plein écran"
>
<Maximize2 className="w-3.5 h-3.5" />
Plein écran
</button>
</div>
</div>
{/* Slides: React renderer (legacy spec) or iframe (new HTML) */}
<div className="flex-1 relative overflow-hidden group">
{data.spec ? (
<SlidesRenderer spec={data.spec} />
) : (
<>
{/* Loading overlay — visible until iframe fires onLoad */}
{!isLoaded && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
<span className="text-xs text-white/30">Chargement de la présentation</span>
</div>
)}
<iframe
ref={iframeRef}
srcDoc={data.html}
className="absolute inset-0 w-full h-full border-0"
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
title={name}
allow="fullscreen"
onLoad={() => setIsLoaded(true)}
/>
{/* Prev button */}
<button
onClick={() => navigate(-1)}
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide précédent"
>
<ChevronLeft className="w-5 h-5" />
</button>
{/* Next button */}
<button
onClick={() => navigate(1)}
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
aria-label="Slide suivant"
>
<ChevronRight className="w-5 h-5" />
</button>
</>
)}
</div>
</div>
)
}
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
@@ -173,6 +331,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
}, 2000)
}
if (scene.slides) {
return <SlidesViewer data={scene.slides} name={name} canvasId={localId} />
}
if (scene.pptx) {
return <PptxViewer data={scene.pptx} name={name} />
}

View File

@@ -0,0 +1,39 @@
'use client'
import { useEffect, useRef } from 'react'
import mermaid from 'mermaid'
let counter = 0
export function MermaidDiagram({ chart, isDark }: { chart: string; isDark?: boolean }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!ref.current || !chart.trim()) return
const id = `mermaid-slide-${++counter}`
mermaid.initialize({
startOnLoad: false,
theme: isDark ? 'dark' : 'default',
themeVariables: isDark
? { primaryColor: '#A47148', primaryTextColor: '#F9F8F6', lineColor: '#D4A373', secondaryColor: '#2A2A2A', tertiaryColor: '#1C1C1C' }
: { primaryColor: '#A47148', primaryTextColor: '#1C1C1C', lineColor: '#D4A373' },
securityLevel: 'loose',
})
mermaid.render(id, chart)
.then(({ svg }) => {
if (ref.current) ref.current.innerHTML = svg
})
.catch((err) => {
console.error('[MermaidDiagram] render error:', err)
if (ref.current) ref.current.innerHTML = `<pre style="color:rgba(255,255,255,0.4);font-size:12px">${chart}</pre>`
})
}, [chart, isDark])
return (
<div
ref={ref}
className="flex justify-center items-center w-full h-full overflow-hidden"
style={{ minHeight: 200 }}
/>
)
}

View File

@@ -0,0 +1,665 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import type { CSSProperties } from 'react'
import dynamic from 'next/dynamic'
import type { PresentationSpec, SlideSpec, Palette } from '@/lib/types/presentation'
import { resolvePalette, resolveRadius } from '@/lib/ai/tools/slides-palettes'
import {
BarChart, Bar, LineChart, Line, AreaChart, Area,
PieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
FunnelChart, Funnel, LabelList,
} from 'recharts'
const MermaidDiagram = dynamic(
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
{ ssr: false, loading: () => <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>Chargement</div> }
)
// ── Constants ──────────────────────────────────────────────────────────────────
const CHART_COLORS = ['#6366f1', '#22d3ee', '#f59e0b', '#ef4444', '#10b981', '#a78bfa', '#fb923c', '#14b8a6']
const TT_STYLE: CSSProperties = { background: '#1C1C1C', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, fontSize: 12 }
const TICK = { fill: 'rgba(255,255,255,0.55)', fontSize: 11 }
const GRID_STROKE = 'rgba(255,255,255,0.07)'
// ── Chart ──────────────────────────────────────────────────────────────────────
function SlideChart({ slide }: { slide: SlideSpec }) {
const { chart } = slide
if (!chart?.data?.length) return null
const colors = chart.colors ?? CHART_COLORS
const xKey = chart.xKey ?? 'name'
const yKeys = chart.yKeys ?? Object.keys(chart.data[0]).filter(k => k !== xKey)
const legend = chart.showLegend !== false && yKeys.length > 1
const legSt = { fontSize: 12, color: 'rgba(255,255,255,0.6)' }
switch (chart.type) {
case 'bar':
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
{legend && <Legend wrapperStyle={legSt} />}
{yKeys.map((k, i) => <Bar key={k} dataKey={k} fill={colors[i % colors.length]} radius={[4, 4, 0, 0]} maxBarSize={56} />)}
</BarChart>
</ResponsiveContainer>
)
case 'line':
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={TT_STYLE} />
{legend && <Legend wrapperStyle={legSt} />}
{yKeys.map((k, i) => <Line key={k} type="monotone" dataKey={k} stroke={colors[i % colors.length]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} activeDot={{ r: 6 }} />)}
</LineChart>
</ResponsiveContainer>
)
case 'area':
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={TT_STYLE} />
{legend && <Legend wrapperStyle={legSt} />}
{yKeys.map((k, i) => <Area key={k} type="monotone" dataKey={k} stroke={colors[i % colors.length]} fill={`${colors[i % colors.length]}28`} strokeWidth={2.5} />)}
</AreaChart>
</ResponsiveContainer>
)
case 'pie':
return (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={chart.data} dataKey={yKeys[0] ?? 'value'} nameKey={xKey} cx="50%" cy="50%" outerRadius="70%" innerRadius="35%" paddingAngle={2}>
{chart.data.map((_, i) => <Cell key={i} fill={colors[i % colors.length]} stroke="transparent" />)}
</Pie>
<Tooltip contentStyle={TT_STYLE} />
{chart.showLegend !== false && <Legend wrapperStyle={legSt} />}
</PieChart>
</ResponsiveContainer>
)
case 'radar':
return (
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={chart.data} cx="50%" cy="50%">
<PolarGrid stroke="rgba(255,255,255,0.1)" />
<PolarAngleAxis dataKey={xKey} tick={{ fill: 'rgba(255,255,255,0.65)', fontSize: 11 }} />
{yKeys.map((k, i) => <Radar key={k} name={k} dataKey={k} stroke={colors[i % colors.length]} fill={colors[i % colors.length]} fillOpacity={0.15} />)}
<Tooltip contentStyle={TT_STYLE} />
{legend && <Legend wrapperStyle={legSt} />}
</RadarChart>
</ResponsiveContainer>
)
case 'stacked-bar':
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
<Legend wrapperStyle={legSt} />
{yKeys.map((k, i) => <Bar key={k} dataKey={k} stackId="a" fill={colors[i % colors.length]} radius={i === yKeys.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]} />)}
</BarChart>
</ResponsiveContainer>
)
case 'combo': {
const lineKeys = chart.lineKeys ?? (yKeys.length > 1 ? [yKeys[yKeys.length - 1]] : [])
const barKeys = yKeys.filter(k => !lineKeys.includes(k))
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
<Legend wrapperStyle={legSt} />
{barKeys.map((k, i) => <Bar key={k} dataKey={k} fill={colors[i % colors.length]} radius={[4, 4, 0, 0]} maxBarSize={56} />)}
{lineKeys.map((k, i) => <Line key={k} type="monotone" dataKey={k} stroke={colors[(barKeys.length + i) % colors.length]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} />)}
</BarChart>
</ResponsiveContainer>
)
}
case 'funnel':
return (
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip contentStyle={TT_STYLE} />
<Funnel dataKey={yKeys[0] ?? 'value'} data={chart.data.map((d, i) => ({ ...d, fill: colors[i % colors.length] }))} isAnimationActive>
<LabelList position="center" fill="#fff" fontSize={12} fontWeight={700} dataKey={xKey} />
</Funnel>
</FunnelChart>
</ResponsiveContainer>
)
case 'gauge': {
const value = chart.gaugeValue ?? (chart.data[0]?.[yKeys[0]] as number) ?? 0
const label = chart.gaugeLabel ?? yKeys[0] ?? ''
const max = 100
const pct = Math.min(Math.max(value / max, 0), 1)
const color = pct > 0.7 ? '#10b981' : pct > 0.4 ? '#f59e0b' : '#ef4444'
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<svg viewBox="0 0 200 120" style={{ width: '60%', maxHeight: '70%' }}>
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="16" strokeLinecap="round" />
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={color} strokeWidth="16" strokeLinecap="round"
strokeDasharray={`${pct * 251.2} 251.2`} />
<text x="100" y="90" textAnchor="middle" fill="#fff" fontSize="28" fontWeight="800">{value}%</text>
<text x="100" y="112" textAnchor="middle" fill="rgba(255,255,255,0.5)" fontSize="11">{label}</text>
</svg>
</div>
)
}
case 'waterfall': {
// Render waterfall as bar chart with cumulative values + special coloring
let cumulative = 0
const waterfallData = chart.data.map((d, i) => {
const val = (d[yKeys[0]] as number) ?? 0
const start = cumulative
cumulative += val
return { ...d, __start: start, __end: cumulative, __delta: val, __isPositive: val >= 0, __isTotal: i === chart.data.length - 1 }
})
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={waterfallData} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
<Tooltip contentStyle={TT_STYLE} />
<Bar dataKey="__start" stackId="w" fill="transparent" />
<Bar dataKey="__delta" stackId="w" radius={[4, 4, 0, 0]} maxBarSize={56}>
{waterfallData.map((d, i) => <Cell key={i} fill={d.__isTotal ? colors[2] : d.__isPositive ? colors[4] : colors[3]} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)
}
case 'treemap': {
// Render treemap as proportional boxes
const total = chart.data.reduce((s, d) => s + ((d[yKeys[0]] as number) ?? 0), 0)
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexWrap: 'wrap', gap: 4, padding: 8, alignContent: 'flex-start' }}>
{chart.data.map((d, i) => {
const val = (d[yKeys[0]] as number) ?? 0
const pct = total > 0 ? (val / total) * 100 : 10
return (
<div key={i} style={{ width: `${Math.max(pct * 2.5, 12)}%`, height: `${Math.max(pct * 1.5, 20)}%`, minWidth: 60, minHeight: 40, background: colors[i % colors.length], borderRadius: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 8 }}>
<span style={{ color: '#fff', fontSize: 11, fontWeight: 700, textAlign: 'center' }}>{d[xKey] as string}</span>
<span style={{ color: 'rgba(255,255,255,0.7)', fontSize: 10 }}>{val}</span>
</div>
)
})}
</div>
)
}
default: return null
}
}
// ── Slide content renderer (per layout) ───────────────────────────────────────
function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; index: number; palette: Palette; radius: string }) {
const layout = slide.layout ?? (index === 0 ? 'title' : 'content')
const isDark = palette.isDark
const text = isDark ? '#f1f5f9' : '#1a1a1a'
const muted = isDark ? 'rgba(241,245,249,0.55)' : 'rgba(0,0,0,0.55)'
const cardBg = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
const cardBorder = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'
const accentBar: CSSProperties = { width: 48, height: 4, background: palette.accent, borderRadius: 2, marginBottom: 24, flexShrink: 0 }
switch (layout) {
// ── TITLE ────────────────────────────────────────────────────────────
case 'title':
return (
<div style={{ width: '100%', height: '100%', background: `linear-gradient(140deg, ${palette.primary} 0%, ${isDark ? palette.bg : palette.secondary} 100%)`, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', padding: '72px 80px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', top: -100, right: -80, width: 500, height: 500, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: -120, right: -20, width: 320, height: 320, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.06)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', top: 56, left: 80, fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' as const, color: 'rgba(255,255,255,0.4)' }}>Présentation</div>
<div style={{ position: 'relative', zIndex: 1 }}>
<div style={{ width: 52, height: 5, background: palette.accent, borderRadius: 3, marginBottom: 20, opacity: 0.9 }} />
<h1 style={{ color: '#fff', fontSize: 56, fontWeight: 800, lineHeight: 1.05, letterSpacing: '-0.04em', margin: 0, maxWidth: 900 }}>{slide.title}</h1>
{slide.subtitle && <p style={{ color: 'rgba(255,255,255,0.6)', fontSize: 20, fontWeight: 300, marginTop: 16, maxWidth: 640, lineHeight: 1.5 }}>{slide.subtitle}</p>}
</div>
</div>
)
// ── SECTION DIVIDER ─────────────────────────────────────────────────
case 'section':
return (
<div style={{ width: '100%', height: '100%', background: isDark ? palette.primary : palette.primary, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '52px 80px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', right: 40, bottom: -30, fontSize: 200, fontWeight: 900, color: 'rgba(255,255,255,0.05)', lineHeight: 1, letterSpacing: '-0.06em', userSelect: 'none' as const, pointerEvents: 'none' as const }}>
{slide.content[0] ?? String(index).padStart(2, '0')}
</div>
<span style={{ display: 'inline-block', background: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)', fontSize: 12, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' as const, padding: '6px 16px', borderRadius: 100, marginBottom: 20, alignSelf: 'flex-start' }}>
Section {slide.content[0] ?? String(index).padStart(2, '0')}
</span>
<h2 style={{ color: '#fff', fontSize: 44, fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 1.05, margin: 0, maxWidth: 780 }}>{slide.title}</h2>
{slide.subtitle && <p style={{ color: 'rgba(255,255,255,0.55)', fontSize: 18, marginTop: 12 }}>{slide.subtitle}</p>}
</div>
)
// ── QUOTE ─────────────────────────────────────────────────────────────
case 'quote':
return (
<div style={{ width: '100%', height: '100%', background: isDark ? '#0d1117' : '#1a1a2e', display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '52px 80px', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', right: -10, top: -40, fontSize: 400, fontWeight: 900, color: 'rgba(255,255,255,0.03)', lineHeight: 0.7, fontFamily: 'Georgia, serif', userSelect: 'none' as const, pointerEvents: 'none' as const }}>"</div>
<div style={{ fontSize: 80, color: palette.accent, lineHeight: 0.6, fontFamily: 'Georgia, serif', marginBottom: 16, opacity: 0.7 }}>"</div>
<blockquote style={{ color: '#fff', fontSize: 28, fontWeight: 600, lineHeight: 1.4, letterSpacing: '-0.02em', margin: 0, maxWidth: 860, fontStyle: 'italic' }}>{slide.title}</blockquote>
{slide.subtitle && <cite style={{ display: 'block', color: 'rgba(255,255,255,0.45)', fontSize: 14, fontStyle: 'normal', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, marginTop: 28 }}> {slide.subtitle}</cite>}
</div>
)
// ── TOC ──────────────────────────────────────────────────────────────
case 'toc':
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'Sommaire'}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{slide.content.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '12px 18px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: '0.08em', color: palette.accent, minWidth: 28 }}>{String(i + 1).padStart(2, '0')}</span>
<span style={{ fontSize: 16, fontWeight: 500, color: text }}>{item}</span>
</div>
))}
</div>
</div>
)
// ── CONTENT (bullets) ────────────────────────────────────────────────
case 'content':
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
{slide.content.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
</div>
))}
</div>
</div>
)
// ── TWO COLUMN ──────────────────────────────────────────────────────
case 'two-column': {
const mid = Math.ceil(slide.content.length / 2)
const left = slide.content.slice(0, mid)
const right = slide.content.slice(mid)
const heads = (slide.subtitle ?? '/').split('/')
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 28, flex: 1, alignContent: 'start' }}>
{[{ items: left, head: heads[0]?.trim() || '' }, { items: right, head: heads[1]?.trim() || '' }].map(({ items, head }, col) => (
<div key={col} style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '20px 24px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
{head && <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase' as const, color: palette.accent, marginBottom: 12 }}>{head}</span>}
{items.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '6px 0' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: palette.accent, marginTop: 7, flexShrink: 0, opacity: 0.7 }} />
<span style={{ fontSize: 15, lineHeight: 1.55, color: text }}>{item}</span>
</div>
))}
</div>
))}
</div>
</div>
)
}
// ── CARDS ────────────────────────────────────────────────────────────
case 'cards': {
const items = slide.content.slice(0, 6)
const cols = items.length <= 2 ? 2 : items.length <= 3 ? 3 : items.length === 4 ? 2 : 3
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 16, flex: 1, alignContent: 'center' }}>
{items.map((item, i) => {
const sep = item.search(/[:\u2014\u2013\-]/)
const head = sep > 0 ? item.slice(0, sep).trim() : ''
const body = sep > 0 ? item.slice(sep + 1).trim() : item
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '20px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}`, position: 'relative', overflow: 'hidden' }}>
<span style={{ position: 'absolute', top: 10, right: 14, fontWeight: 900, fontSize: 28, color: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', lineHeight: 1 }}>{String(i + 1).padStart(2, '0')}</span>
<div style={{ width: 24, height: 3, background: palette.accent, borderRadius: 2 }} />
{head && <p style={{ margin: 0, fontSize: 15, fontWeight: 700, letterSpacing: '0.02em', color: text }}>{head}</p>}
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.55, color: muted }}>{body}</p>
</div>
)
})}
</div>
</div>
)
}
// ── STATS ────────────────────────────────────────────────────────────
case 'stats': {
const items = slide.content.slice(0, 4)
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length}, 1fr)`, gap: 20, flex: 1, alignContent: 'center' }}>
{items.map((item, i) => {
const parts = item.split(/[-–—:]/)
const val = parts[0]?.trim() ?? item
const lbl = parts.slice(1).join(' ').trim()
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', padding: '28px 24px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
<span style={{ fontSize: 42, fontWeight: 900, letterSpacing: '-0.04em', color: palette.accent, lineHeight: 1 }}>{val}</span>
<div style={{ width: 32, height: 3, background: palette.accent, borderRadius: 2, margin: '12px 0 10px', opacity: 0.6 }} />
{lbl && <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: muted }}>{lbl}</span>}
</div>
)
})}
</div>
</div>
)
}
// ── SUMMARY ──────────────────────────────────────────────────────────
case 'summary':
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'En résumé'}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, justifyContent: 'center' }}>
{slide.content.slice(0, 6).map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '14px 20px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
<div style={{ width: 26, height: 26, minWidth: 26, background: palette.accent, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: '#fff', fontWeight: 900 }}></div>
<span style={{ fontSize: 16, lineHeight: 1.45, color: text }}>{item}</span>
</div>
))}
</div>
</div>
)
// ── CHART ────────────────────────────────────────────────────────────
case 'chart':
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', background: isDark ? palette.bg : '#111827', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.04em', color: '#fff' }}>{slide.title}</h2>
{slide.subtitle && <p style={{ margin: '6px 0 0', fontSize: 15, color: 'rgba(255,255,255,0.5)' }}>{slide.subtitle}</p>}
<div style={{ flex: 1, marginTop: 24 }}>
<SlideChart slide={slide} />
</div>
</div>
)
// ── DIAGRAM ──────────────────────────────────────────────────────────
case 'diagram':
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', background: isDark ? palette.bg : '#111827', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.04em', color: '#fff' }}>{slide.title}</h2>
{slide.subtitle && <p style={{ margin: '6px 0 0', fontSize: 15, color: 'rgba(255,255,255,0.5)' }}>{slide.subtitle}</p>}
<div style={{ flex: 1, marginTop: 20, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{slide.mermaid ? <MermaidDiagram chart={slide.mermaid} isDark={true} /> : <p style={{ color: 'rgba(255,255,255,0.3)' }}>No diagram</p>}
</div>
</div>
)
// ── IMAGE ────────────────────────────────────────────────────────────
case 'image':
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{slide.imageUrl
? <img src={slide.imageUrl} alt={slide.title} style={{ maxHeight: '70%', maxWidth: '90%', borderRadius: radius, objectFit: 'contain' }} />
: <div style={{ color: muted, fontSize: 14 }}>No image</div>
}
</div>
{slide.content[0] && <p style={{ margin: '12px 0 0', fontSize: 13, textAlign: 'center', color: muted }}>{slide.content[0]}</p>}
</div>
)
// ── TIMELINE ────────────────────────────────────────────────────────
case 'timeline': {
const items = slide.content.slice(0, 8)
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', paddingLeft: 28 }}>
<div style={{ position: 'absolute', left: 11, top: 0, bottom: 0, width: 2, background: `linear-gradient(to bottom, ${palette.accent}, transparent)` }} />
{items.map((item, i) => {
const sep = item.search(/[:\u2014\u2013]/)
const date = sep > 0 ? item.slice(0, sep).trim() : ''
const desc = sep > 0 ? item.slice(sep + 1).trim() : item
return (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, marginBottom: 16, position: 'relative' }}>
<div style={{ position: 'absolute', left: -22, top: 6, width: 12, height: 12, borderRadius: '50%', background: palette.accent, border: `3px solid ${isDark ? palette.bg : '#fff'}`, zIndex: 1 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingLeft: 8 }}>
{date && <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: palette.accent }}>{date}</span>}
<span style={{ fontSize: 15, lineHeight: 1.5, color: text }}>{desc}</span>
</div>
</div>
)
})}
</div>
</div>
)
}
// ── KPI DASHBOARD ───────────────────────────────────────────────────
case 'kpi-dashboard': {
const items = slide.content.slice(0, 6)
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length <= 3 ? items.length : 3}, 1fr)`, gap: 14, flex: 1, alignContent: 'center' }}>
{items.map((item, i) => {
// Format: "value | label | trend" or "value — label (trend)"
const parts = item.split(/[|]/).map(s => s.trim())
const val = parts[0] ?? item
const lbl = parts[1] ?? ''
const trend = parts[2] ?? ''
const isUp = trend.includes('↑') || trend.includes('+')
const isDown = trend.includes('↓') || trend.includes('-')
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '20px 18px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
<span style={{ fontSize: 32, fontWeight: 900, letterSpacing: '-0.03em', color: palette.accent, lineHeight: 1 }}>{val}</span>
{lbl && <span style={{ fontSize: 12, fontWeight: 600, color: muted, letterSpacing: '0.05em', textTransform: 'uppercase' as const }}>{lbl}</span>}
{trend && <span style={{ fontSize: 12, fontWeight: 700, color: isUp ? '#10b981' : isDown ? '#ef4444' : muted }}>{trend}</span>}
</div>
)
})}
</div>
</div>
)
}
// ── DATA TABLE ──────────────────────────────────────────────────────
case 'data-table': {
const headers = slide.tableHeaders ?? (slide.content[0]?.split('|').map(s => s.trim()) ?? [])
const rows = slide.tableRows ?? slide.content.slice(headers === slide.tableHeaders ? 0 : 1).map(row => row.split('|').map(s => s.trim()))
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ flex: 1, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
{headers.length > 0 && (
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} style={{ padding: '10px 14px', borderBottom: `2px solid ${palette.accent}`, textAlign: 'left', fontWeight: 700, fontSize: 11, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: palette.accent }}>{h}</th>
))}
</tr>
</thead>
)}
<tbody>
{rows.map((row, ri) => (
<tr key={ri} style={{ background: ri % 2 === 0 ? 'transparent' : cardBg }}>
{row.map((cell, ci) => (
<td key={ci} style={{ padding: '10px 14px', borderBottom: `1px solid ${cardBorder}`, color: text, lineHeight: 1.4 }}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ── DEFAULT ──────────────────────────────────────────────────────────
default:
return (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
<div style={accentBar} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
{slide.content.map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
</div>
))}
</div>
</div>
)
}
}
// ══════════════════════════════════════════════════════════════════════════════
// MAIN RENDERER — Pure React + CSS transitions (no Reveal.js dependency)
// ══════════════════════════════════════════════════════════════════════════════
export interface SlidesRendererProps {
spec: PresentationSpec
}
export function SlidesRenderer({ spec }: SlidesRendererProps) {
const [current, setCurrent] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const total = spec.slides.length
const { palette } = resolvePalette(spec)
const radius = resolveRadius(spec.style)
const isDark = palette.isDark
const next = useCallback(() => setCurrent(c => Math.min(c + 1, total - 1)), [total])
const prev = useCallback(() => setCurrent(c => Math.max(c - 1, 0)), [])
// Keyboard + touch navigation
useEffect(() => {
const el = containerRef.current
if (!el) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); next() }
if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); prev() }
}
let startX = 0
const onTouchStart = (e: TouchEvent) => { startX = e.touches[0].clientX }
const onTouchEnd = (e: TouchEvent) => {
const diff = e.changedTouches[0].clientX - startX
if (Math.abs(diff) > 50) { diff < 0 ? next() : prev() }
}
el.addEventListener('keydown', onKey)
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchend', onTouchEnd, { passive: true })
el.focus()
return () => {
el.removeEventListener('keydown', onKey)
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchend', onTouchEnd)
}
}, [next, prev])
// Nav button style
const navBtn = (isLeft: boolean): CSSProperties => ({
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
...(isLeft ? { left: 16 } : { right: 16 }),
zIndex: 50,
width: 44,
height: 44,
borderRadius: '50%',
border: `1px solid ${isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)'}`,
background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.85)',
color: isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.6)',
fontSize: 20,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(8px)',
transition: 'all 0.15s',
userSelect: 'none' as const,
padding: 0,
boxShadow: isDark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
})
return (
<div
ref={containerRef}
tabIndex={0}
style={{
position: 'absolute', inset: 0,
background: palette.bg,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
overflow: 'hidden',
outline: 'none',
}}
>
{/* Slides */}
<div style={{ position: 'absolute', inset: 0 }}>
{spec.slides.map((slide, i) => {
const offset = i - current
return (
<div
key={i}
style={{
position: 'absolute',
inset: 0,
transform: `translateX(${offset * 100}%)`,
transition: 'transform 0.45s cubic-bezier(0.4, 0, 0.2, 1)',
willChange: 'transform',
}}
>
<SlideContent slide={slide} index={i} palette={palette} radius={radius} />
</div>
)
})}
</div>
{/* Navigation buttons */}
{current > 0 && (
<button style={navBtn(true)} onClick={prev} aria-label="Précédent">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
</button>
)}
{current < total - 1 && (
<button style={navBtn(false)} onClick={next} aria-label="Suivant">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
</button>
)}
{/* Progress bar */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 3, background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', zIndex: 40 }}>
<div style={{ height: '100%', width: `${((current + 1) / total) * 100}%`, background: palette.accent, transition: 'width 0.4s ease', borderRadius: '0 2px 2px 0' }} />
</div>
{/* Slide counter */}
<div style={{ position: 'absolute', bottom: 12, right: 16, zIndex: 40, fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)' }}>
{current + 1} / {total}
</div>
</div>
)
}

View File

@@ -48,6 +48,7 @@ import { hi } from 'date-fns/locale/hi'
import { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl'
import { LabelBadge } from './label-badge'
import DOMPurify from 'isomorphic-dompurify'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
@@ -196,11 +197,7 @@ export const NoteCard = memo(function NoteCard({
const sanitizedHtml = useMemo(() => {
if (note.type !== 'richtext' || !note.content) return ''
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('isomorphic-dompurify').sanitize(note.content)
}
return note.content
return DOMPurify.sanitize(note.content)
}, [note.type, note.content])
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {

View File

@@ -22,6 +22,7 @@ import { NoteAttachments } from '@/components/note-attachments'
import { DocumentQAOverlay } from '@/components/document-qa-overlay'
import { useLanguage } from '@/lib/i18n'
import { useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
interface NoteEditorFullPageProps {
onClose: () => void
@@ -114,6 +115,9 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
onCountChange={setAttachmentsCount}
triggerUpload={uploadTrigger}
/>
{/* WikiLinks backlinks */}
<WikilinksBacklinksPanel noteId={note.id} />
</div>
</div>
</div>

View File

@@ -91,7 +91,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
if (mode === 'fullPage') {
return (
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
<div className="px-4 sm:px-8 md:px-12 py-4 sm:py-6 md:py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
<button
onClick={onClose}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"

View File

@@ -0,0 +1,354 @@
'use client'
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Loader2, Network, Filter, X, ExternalLink, Maximize2 } from 'lucide-react'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
interface GraphNode { id: string; title: string; notebookId: string | null; createdAt: string; degree: number }
interface GraphEdge { source: string; target: string; weight: number; type: string }
interface Cluster { id: string; name: string }
interface RawData { nodes: GraphNode[]; edges: GraphEdge[]; clusters: Cluster[] }
interface NotePreview { id: string; title: string; content: string; createdAt: string }
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
export function NoteGraphView() {
const router = useRouter()
const containerRef = useRef<HTMLDivElement>(null)
const graphRef = useRef<any>(null)
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
const [rawData, setRawData] = useState<RawData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchFilter, setSearchFilter] = useState('')
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
// ─── Resize ───────────────────────────────────────────────────────────────
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect
setDimensions({ width: Math.floor(width), height: Math.floor(height) })
})
ro.observe(el)
return () => ro.disconnect()
}, [])
// ─── Fetch data ───────────────────────────────────────────────────────────
useEffect(() => {
setLoading(true)
fetch('/api/graph')
.then(r => { if (!r.ok) throw new Error('Erreur réseau'); return r.json() })
.then(d => setRawData(d))
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [])
// ─── Configure forces once graph is mounted ───────────────────────────────
const forcesConfigured = useRef(false)
useEffect(() => {
if (!rawData || forcesConfigured.current) return
// Wait for the ForceGraph to mount
const timer = setTimeout(() => {
const fg = graphRef.current
if (!fg) return
fg.d3Force('charge')?.strength(-120)
fg.d3Force('link')?.distance(55)
fg.d3Force('center')?.strength(0.05)
forcesConfigured.current = true
}, 200)
return () => clearTimeout(timer)
}, [rawData])
// ─── Note preview ─────────────────────────────────────────────────────────
useEffect(() => {
if (!selectedNode) { setNotePreview(null); return }
setPreviewLoading(true)
fetch(`/api/notes/${selectedNode.id}`)
.then(r => r.ok ? r.json() : null)
.then(res => setNotePreview(res?.data ?? null))
.catch(() => setNotePreview(null))
.finally(() => setPreviewLoading(false))
}, [selectedNode])
// ─── Color map ────────────────────────────────────────────────────────────
const colorMap = useMemo(() => {
if (!rawData) return new Map<string | null, string>()
const map = new Map<string | null, string>()
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))]
ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length]))
return map
}, [rawData])
// ─── Graph data ───────────────────────────────────────────────────────────
const graphData = useMemo(() => {
if (!rawData) return { nodes: [], links: [] }
const filtered = searchFilter.trim()
? rawData.nodes.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
: rawData.nodes
const filteredIds = new Set(filtered.map(n => n.id))
return {
nodes: filtered.map(n => ({
id: n.id,
name: n.title,
val: 1 + Math.min(n.degree, 8) * 0.5,
color: colorMap.get(n.notebookId) ?? '#94a3b8',
notebookId: n.notebookId,
degree: n.degree,
})),
links: rawData.edges
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
.map(e => ({
source: e.source,
target: e.target,
color: e.type === 'title_mention' ? '#f59e0b' : e.type === 'shared_label' ? '#6366f1' : '#e2e8f0',
width: e.type === 'title_mention' ? 2 : e.type === 'shared_label' ? 1.5 : 0.6,
})),
}
}, [rawData, searchFilter, colorMap])
// ─── Handlers (double-click via timer) ──────────────────────────────────
const lastClickRef = useRef<{ id: string; time: number } | null>(null)
const handleNodeClick = useCallback((node: any) => {
if (!rawData) return
const now = Date.now()
const last = lastClickRef.current
if (last && last.id === node.id && now - last.time < 350) {
// Double-click → zoom
lastClickRef.current = null
graphRef.current?.centerAt(node.x, node.y, 600)
graphRef.current?.zoom(3, 600)
return
}
lastClickRef.current = { id: node.id, time: now }
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
}, [rawData])
const handleZoomToFit = useCallback(() => {
graphRef.current?.zoomToFit(400, 50)
}, [])
const plainText = (html: string | null | undefined) =>
(html ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/#{1,6}\s/g, '')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\s+/g, ' ').trim().slice(0, 400)
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
const dataRef = useRef<{ nodes: any[]; colorMap: Map<string|null,string>; clusters: Cluster[] }>({ nodes: [], colorMap: new Map(), clusters: [] })
dataRef.current = { nodes: graphData.nodes, colorMap, clusters: rawData?.clusters ?? [] }
const paintClusters = useRef((ctx: CanvasRenderingContext2D, globalScale: number) => {
const { nodes, colorMap: cm, clusters } = dataRef.current
if (!nodes || nodes.length === 0) return
const groups = new Map<string, { x: number; y: number }[]>()
for (const node of nodes) {
if (!node.notebookId || node.x === undefined || node.y === undefined) continue
if (!groups.has(node.notebookId)) groups.set(node.notebookId, [])
groups.get(node.notebookId)!.push({ x: node.x, y: node.y })
}
for (const [nbId, pts] of groups) {
if (pts.length < 3) continue
const color = cm.get(nbId) ?? '#94a3b8'
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
let maxR = 0
for (const p of pts) {
const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2)
if (d > maxR) maxR = d
}
const r = maxR + 30
ctx.beginPath()
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
ctx.fillStyle = color + '0A'
ctx.fill()
ctx.strokeStyle = color + '30'
ctx.lineWidth = 1.5 / globalScale
ctx.setLineDash([5 / globalScale, 5 / globalScale])
ctx.stroke()
ctx.setLineDash([])
// Cluster name
if (globalScale > 0.4) {
const name = clusters.find(c => c.id === nbId)?.name ?? ''
if (name) {
const fs = Math.min(12, 9 / globalScale)
ctx.font = `600 ${fs}px -apple-system, sans-serif`
ctx.fillStyle = color + 'BB'
ctx.textAlign = 'center'
ctx.textBaseline = 'bottom'
ctx.fillText(name, cx, cy - r + 4)
}
}
}
}).current
// ─── Render ───────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-full bg-[#FAFAF9]">
{/* Header */}
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
<Network size={16} className="text-indigo-500" />
<h1 className="text-sm font-semibold text-ink">Vue en graphe</h1>
{rawData && (
<span className="text-[10px] text-concrete/50 font-medium">
{rawData.nodes.length} notes · {rawData.edges.length} liens
</span>
)}
<div className="flex-1" />
<div className="relative">
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete/40" />
<input
type="text"
placeholder="Filtrer…"
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="pl-7 pr-7 py-1.5 bg-white border border-border/60 rounded-md text-xs text-ink outline-none focus:border-indigo-400 w-44 placeholder:text-concrete/40"
/>
{searchFilter && (
<button onClick={() => setSearchFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-concrete/40 hover:text-ink">
<X size={12} />
</button>
)}
</div>
</div>
{/* Canvas */}
<div ref={containerRef} className="flex-1 relative overflow-hidden">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-[#FAFAF9]">
<Loader2 size={24} className="animate-spin text-concrete/40" />
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm text-rose-500">{error}</p>
</div>
)}
{!loading && !error && graphData.nodes.length > 0 && (
<ForceGraph2D
ref={graphRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
backgroundColor="#FAFAF9"
nodeRelSize={5}
nodeVal="val"
nodeColor="color"
nodeLabel="name"
linkColor="color"
linkWidth="width"
onNodeClick={handleNodeClick}
onNodeHover={(node: any) => {
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
}}
onRenderFramePre={paintClusters}
nodeCanvasObjectMode={() => 'after'}
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
if (globalScale < 0.7) return
const name: string = node.name ?? ''
const label = name.length > 20 ? name.slice(0, 18) + '…' : name
const fontSize = 11 / globalScale
if (fontSize > 18) return
ctx.font = `${fontSize}px -apple-system, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const r = Math.sqrt(node.val ?? 1) * 5
// White background behind label
const tw = ctx.measureText(label).width
const lx = node.x - tw / 2 - 2
const ly = node.y + r + 2
ctx.fillStyle = 'rgba(250,250,249,0.85)'
ctx.fillRect(lx, ly, tw + 4, fontSize + 2)
// Label text
ctx.fillStyle = '#334155'
ctx.fillText(label, node.x, ly + 1)
}}
cooldownTicks={80}
d3AlphaDecay={0.03}
d3VelocityDecay={0.4}
/>
)}
{!loading && !error && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-concrete/40">
<Network size={32} />
<p className="text-xs">Aucune note trouvée</p>
</div>
)}
{/* Zoom to fit */}
{!loading && graphData.nodes.length > 0 && (
<button
onClick={handleZoomToFit}
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors"
>
<Maximize2 size={12} />
Vue globale
</button>
)}
{/* Cluster legend */}
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1.5 max-h-[50vh] overflow-y-auto pr-1">
{rawData.clusters.map(c => (
<div key={c.id} className="flex items-center gap-1.5 px-2 py-0.5 bg-white/90 border border-border/30 rounded-full shadow-sm">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(c.id) ?? '#94a3b8' }} />
<span className="text-[9px] font-medium text-concrete/70 whitespace-nowrap">{c.name}</span>
</div>
))}
</div>
)}
{/* Note detail panel */}
{selectedNode && (
<div className="absolute inset-y-0 right-0 w-80 bg-white border-l border-border/50 flex flex-col shadow-xl z-20">
<div className="flex items-start justify-between p-4 border-b border-border/30">
<div className="flex-1 min-w-0 pr-2">
<h2 className="text-sm font-semibold text-ink leading-tight">{selectedNode.title}</h2>
<p className="text-[10px] text-concrete/50 mt-1">
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
{' · '}{selectedNode.degree} connexion{selectedNode.degree !== 1 ? 's' : ''}
</p>
</div>
<button onClick={() => setSelectedNode(null)} className="p-1 rounded text-concrete/40 hover:text-ink hover:bg-black/5">
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{previewLoading ? (
<Loader2 size={16} className="animate-spin text-concrete/30 mx-auto mt-8" />
) : (
<p className="text-xs text-concrete/70 leading-relaxed">
{plainText(notePreview?.content) || <span className="italic text-concrete/30">Note vide</span>}
</p>
)}
</div>
<div className="p-3 border-t border-border/30">
<button
onClick={() => router.push(`/notes/${selectedNode.id}`)}
className="w-full flex items-center justify-center gap-2 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-md transition-colors"
>
<ExternalLink size={12} />
Ouvrir la note
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,272 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import {
Wrench, TrendingUp, Users, ThumbsDown, Sun,
Loader2, ChevronDown, ChevronUp, X, Copy, CheckCircle, Sparkles,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/markdown-content'
// ── Personas definition ──────────────────────────────────────────────────────
const PERSONAS = [
{
id: 'engineer',
label: 'Ingénieur',
sublabel: 'Faisabilité · Risques tech',
icon: Wrench,
color: 'text-blue-600',
bg: 'bg-blue-50 dark:bg-blue-950/30',
border: 'border-blue-200 dark:border-blue-800/50',
hoverBorder: 'hover:border-blue-400/60',
activeBg: 'bg-blue-600',
},
{
id: 'financial',
label: 'Financier',
sublabel: 'ROI · Coûts · Viabilité',
icon: TrendingUp,
color: 'text-emerald-600',
bg: 'bg-emerald-50 dark:bg-emerald-950/30',
border: 'border-emerald-200 dark:border-emerald-800/50',
hoverBorder: 'hover:border-emerald-400/60',
activeBg: 'bg-emerald-600',
},
{
id: 'customer',
label: 'Client',
sublabel: 'UX · Valeur · Besoins',
icon: Users,
color: 'text-violet-600',
bg: 'bg-violet-50 dark:bg-violet-950/30',
border: 'border-violet-200 dark:border-violet-800/50',
hoverBorder: 'hover:border-violet-400/60',
activeBg: 'bg-violet-600',
},
{
id: 'skeptic',
label: 'Sceptique',
sublabel: 'Points faibles · Angles morts',
icon: ThumbsDown,
color: 'text-rose-600',
bg: 'bg-rose-50 dark:bg-rose-950/30',
border: 'border-rose-200 dark:border-rose-800/50',
hoverBorder: 'hover:border-rose-400/60',
activeBg: 'bg-rose-600',
},
{
id: 'optimist',
label: 'Optimiste',
sublabel: 'Opportunités · Potentiel',
icon: Sun,
color: 'text-amber-600',
bg: 'bg-amber-50 dark:bg-amber-950/30',
border: 'border-amber-200 dark:border-amber-800/50',
hoverBorder: 'hover:border-amber-400/60',
activeBg: 'bg-amber-600',
},
] as const
type PersonaId = (typeof PERSONAS)[number]['id']
interface PersonaResult {
personaId: PersonaId
personaLabel: string
analysis: string
}
interface PersonasPanelProps {
noteTitle?: string
noteContent?: string
}
// ── Component ─────────────────────────────────────────────────────────────────
export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
const [loadingId, setLoadingId] = useState<PersonaId | null>(null)
const [results, setResults] = useState<Map<PersonaId, PersonaResult>>(new Map())
const [expanded, setExpanded] = useState<PersonaId | null>(null)
const [copied, setCopied] = useState<PersonaId | null>(null)
const handlePersona = async (personaId: PersonaId) => {
if (loadingId) return
// Toggle off if already showing
if (expanded === personaId && results.has(personaId)) {
setExpanded(null)
return
}
// If we already have the result, just expand it
if (results.has(personaId)) {
setExpanded(personaId)
return
}
if (!noteContent || noteContent.replace(/<[^>]+>/g, '').trim().split(/\s+/).length < 5) {
return
}
setLoadingId(personaId)
try {
const res = await fetch('/api/ai/personas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Erreur')
setResults(prev => new Map(prev).set(personaId, data))
setExpanded(personaId)
} catch {
// silent error — user can retry
} finally {
setLoadingId(null)
}
}
const handleCopy = (personaId: PersonaId) => {
const result = results.get(personaId)
if (!result) return
navigator.clipboard.writeText(result.analysis).catch(() => {})
setCopied(personaId)
setTimeout(() => setCopied(null), 2000)
}
const handleClear = (personaId: PersonaId) => {
setResults(prev => {
const next = new Map(prev)
next.delete(personaId)
return next
})
if (expanded === personaId) setExpanded(null)
}
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap flex items-center gap-1.5">
<Sparkles size={10} className="text-brand-accent" />
Prismes IA
</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<p className="text-[10px] text-concrete/60 leading-relaxed text-center px-2">
Réinterprétez vos notes à travers différents prismes d'analyse.
</p>
{/* Persona buttons */}
<div className="space-y-2">
{PERSONAS.map((persona) => {
const Icon = persona.icon
const isLoading = loadingId === persona.id
const hasResult = results.has(persona.id)
const isExpanded = expanded === persona.id
const result = results.get(persona.id)
return (
<div key={persona.id} className="rounded-xl overflow-hidden border border-border/60">
{/* Persona row */}
<button
type="button"
onClick={() => handlePersona(persona.id)}
disabled={!!loadingId && !isLoading}
className={cn(
'w-full flex items-center gap-3 p-3.5 transition-all text-left group',
hasResult ? cn(persona.bg, persona.border, 'border-b') : 'bg-white/50 dark:bg-white/5 hover:bg-white dark:hover:bg-white/10',
isLoading && 'animate-pulse',
!!loadingId && !isLoading && 'opacity-40 cursor-not-allowed',
)}
>
<div className={cn(
'p-2 rounded-lg shrink-0 transition-colors',
hasResult ? cn(persona.bg, 'border', persona.border) : 'bg-slate-50 dark:bg-white/10',
!hasResult && `group-hover:${persona.activeBg} group-hover:text-white`,
persona.color,
)}>
{isLoading
? <Loader2 size={14} className="animate-spin" />
: <Icon size={14} />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={cn('text-[11px] font-bold text-ink', hasResult && persona.color)}>
{persona.label}
</span>
{hasResult && (
<span className={cn('text-[8px] font-bold uppercase tracking-widest px-1.5 py-0.5 rounded-full', persona.bg, persona.color)}>
prêt
</span>
)}
</div>
<p className="text-[9px] text-concrete/60 uppercase tracking-tight">{persona.sublabel}</p>
</div>
{hasResult
? isExpanded ? <ChevronUp size={14} className="text-concrete shrink-0" /> : <ChevronDown size={14} className="text-concrete shrink-0" />
: null
}
</button>
{/* Expanded result */}
<AnimatePresence>
{isExpanded && result && (
<motion.div
key="result"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className={cn('p-4 space-y-3', persona.bg)}>
<div className="text-[11px] leading-relaxed text-ink/80">
<MarkdownContent content={result.analysis} />
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => handleCopy(persona.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
>
{copied === persona.id
? <><CheckCircle size={10} className={persona.color} /> Copié</>
: <><Copy size={10} /> Copier</>
}
</button>
<button
type="button"
onClick={() => handleClear(persona.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/70 dark:bg-black/20 border border-border/40 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-rose-500 transition-all"
>
<X size={10} /> Effacer
</button>
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-border/40 bg-white/70 dark:bg-black/20 text-[9px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-all"
title="Regénérer"
onClick={(e) => {
e.stopPropagation()
handleClear(persona.id)
setTimeout(() => handlePersona(persona.id), 50)
}}
>
Regénérer
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -28,12 +28,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
return (
<nav className={`flex flex-wrap items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
<nav className={`flex overflow-x-auto items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
{tabs.map((tab) => (
<Link
key={tab.id}
href={tab.href}
className="flex items-center gap-2.5 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
className="flex items-center gap-1.5 sm:gap-2.5 px-2 sm:px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
>
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>

View File

@@ -29,6 +29,7 @@ import {
PinOff,
Sparkles,
Home,
Network,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -182,6 +183,7 @@ function SidebarCarnetItem({
isExpanded,
toggleExpand,
hasChildNotebooks,
hasActiveDescendant,
}: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean
@@ -201,6 +203,7 @@ function SidebarCarnetItem({
isExpanded: boolean
toggleExpand: () => void
hasChildNotebooks?: boolean
hasActiveDescendant?: boolean
}) {
const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar'
@@ -287,6 +290,9 @@ function SidebarCarnetItem({
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
{!isActive && hasActiveDescendant && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
@@ -424,6 +430,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
const [renameValue, setRenameValue] = useState('')
const [isDark, setIsDark] = useState(false)
const [isMobileOpen, setIsMobileOpen] = useState(false)
// Écoute l'événement d'ouverture du menu mobile
useEffect(() => {
const handler = () => setIsMobileOpen(true)
window.addEventListener('open-mobile-sidebar', handler)
return () => window.removeEventListener('open-mobile-sidebar', handler)
}, [])
// Ferme la sidebar mobile lors d'une navigation
useEffect(() => {
setIsMobileOpen(false)
}, [pathname, searchParams])
useEffect(() => {
setIsDark(document.documentElement.classList.contains('dark'))
@@ -631,22 +650,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
} catch {}
}, [])
// Auto-expand all parent notebooks on first load
useEffect(() => {
if (orderedNotebooks.length === 0) return
const parentIds = new Set<string>()
for (const nb of orderedNotebooks) {
if (nb.parentId) parentIds.add(nb.parentId)
}
if (parentIds.size > 0) {
setExpandedIds(prev => {
const next = new Set(prev)
for (const id of parentIds) next.add(id)
return next
})
}
}, [orderedNotebooks])
const togglePin = useCallback((id: string) => {
setPinnedIds(prev => {
const next = new Set(prev)
@@ -730,8 +733,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
return desc.some(c => currentNotebookId === c.id || hasDescendant(c.id))
}
const hasActiveDescendant = hasDescendant(notebook.id)
// A notebook stays expanded if: manually expanded, has active descendant, OR is pinned
const isExpanded = expandedIds.has(notebook.id) || hasActiveDescendant || pinnedIds.has(notebook.id)
// A notebook stays expanded if: manually expanded OR is pinned
const isExpanded = expandedIds.has(notebook.id) || pinnedIds.has(notebook.id)
return (
<motion.div key={notebook.id}>
@@ -784,7 +787,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
toggleExpand(notebook.id)
} else {
handleCarnetClick(notebook.id)
if (!expandedIds.has(notebook.id)) toggleExpand(notebook.id)
}
}}
onNoteClick={handleNoteClick}
@@ -802,6 +804,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
isExpanded={isExpanded}
toggleExpand={() => toggleExpand(notebook.id)}
hasChildNotebooks={children.length > 0}
hasActiveDescendant={hasActiveDescendant}
/>
</div>
{isExpanded && renderCarnetTree(notebook.id, level + 1)}
@@ -812,10 +815,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
return (
<>
{/* Overlay mobile */}
<AnimatePresence>
{isMobileOpen && (
<motion.div
key="sidebar-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[60] md:hidden"
onClick={() => setIsMobileOpen(false)}
/>
)}
</AnimatePresence>
<aside
className={cn(
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex',
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
// Mobile: fixed overlay, slide in/out
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
'h-full min-h-0 w-72 lg:w-80 shrink-0 flex flex-col',
'transition-transform duration-300 ease-in-out',
isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0',
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
className
)}
>
@@ -1145,7 +1167,25 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
)}
</Link>
<div className="my-2 h-px bg-border/20 mx-2" />
{/* ── Intelligence section ── */}
<div className="pt-3 border-t border-border/20 mx-2 mt-1 space-y-0.5">
<p className="text-[9px] font-bold text-muted-ink tracking-[0.2em] uppercase px-1 mb-1 opacity-60">Intelligence</p>
<Link
href="/graph"
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
pathname === '/graph'
? 'bg-indigo-500/10 text-indigo-500'
: 'text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5'
)}
>
<Network
size={14}
className={pathname === '/graph' ? 'text-indigo-500' : 'text-muted-ink group-hover:text-indigo-500'}
/>
<span className="flex-1">Vue en graphe</span>
</Link>
</div>
</div>
</div>
</aside>

View File

@@ -0,0 +1,111 @@
'use client'
import { useEffect, useState } from 'react'
import { Link2, ChevronRight, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { motion, AnimatePresence } from 'motion/react'
import { useRouter } from 'next/navigation'
interface BacklinkNote {
id: string
title: string | null
updatedAt: string
notebookId: string | null
}
interface Backlink {
id: string
sourceNote: BacklinkNote
contextSnippet: string | null
createdAt: string
}
interface WikilinksBacklinksPanelProps {
noteId: string
className?: string
}
export function WikilinksBacklinksPanel({ noteId, className }: WikilinksBacklinksPanelProps) {
const [backlinks, setBacklinks] = useState<Backlink[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(true)
const router = useRouter()
useEffect(() => {
if (!noteId) return
setLoading(true)
fetch(`/api/notes/${noteId}/backlinks`)
.then(r => r.json())
.then(data => setBacklinks(data.backlinks || []))
.catch(() => {})
.finally(() => setLoading(false))
}, [noteId])
if (loading && backlinks.length === 0) return null
if (!loading && backlinks.length === 0) return null
return (
<div className={cn('space-y-2', className)}>
{/* Header */}
<button
type="button"
onClick={() => setOpen(o => !o)}
className="flex items-center gap-2 group w-full"
>
<Link2 size={14} className="text-concrete shrink-0" />
<span className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete group-hover:text-ink transition-colors">
Liens entrants
</span>
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full font-bold">
{backlinks.length}
</span>
<div className="h-px flex-1 bg-border/40" />
<ChevronRight
size={12}
className={cn('text-concrete transition-transform', open && 'rotate-90')}
/>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden pl-5 space-y-1.5"
>
{loading && (
<div className="flex items-center gap-2 py-2">
<Loader2 size={12} className="animate-spin text-concrete" />
<span className="text-[10px] text-concrete">Chargement</span>
</div>
)}
{backlinks.map(bl => (
<button
key={bl.id}
type="button"
onClick={() => router.push(`/notes/${bl.sourceNote.id}`)}
className="w-full text-left group/bl p-2.5 rounded-lg bg-white/50 dark:bg-white/5 border border-border/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all"
>
<div className="flex items-start gap-2">
<Link2 size={10} className="text-brand-accent/60 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold text-ink truncate group-hover/bl:text-brand-accent transition-colors">
{bl.sourceNote.title || '(Sans titre)'}
</p>
{bl.contextSnippet && (
<p className="text-[9px] text-concrete/70 mt-0.5 line-clamp-2 leading-relaxed">
{bl.contextSnippet}
</p>
)}
</div>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -335,3 +335,62 @@ export function useBrainstormSnapshots(sessionId: string | null) {
enabled: !!sessionId,
})
}
export function useStarIdea(sessionId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (ideaId: string) => {
const res = await fetch(`/api/brainstorm/${sessionId}/star`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ideaId }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || 'Failed to star idea')
return data.data as { ideaId: string; isStarred: boolean }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.brainstormSession(sessionId) })
},
})
}
export function useSummarizeBrainstorm(sessionId: string) {
return useMutation({
mutationFn: async (locale?: string): Promise<string> => {
const res = await fetch(`/api/brainstorm/${sessionId}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ locale }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || 'Failed to summarize')
return data.data.summary as string
},
})
}
export function useRenameBrainstormSession(sessionId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (seedIdea: string) => {
const res = await fetch(`/api/brainstorm/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ seedIdea }),
})
const data = await res.json()
if (!data.success) throw new Error(data.error || 'Failed to rename session')
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.brainstormSession(sessionId) })
queryClient.invalidateQueries({ queryKey: queryKeys.brainstormSessions() })
},
})
}

View File

@@ -914,32 +914,94 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
},
'slide-generator': {
fr: `Tu es un designer de présentations visuelles de classe mondiale (style Manus AI / Beautiful.ai). Tu reçois du contenu de notes et tu dois créer une présentation PowerPoint (.pptx) professionnelle, moderne et visuellement riche.
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
Tu dois OBLIGATOIREMENT appeler l'outil generate_pptx. Ne réponds JAMAIS avec du texte — appelle l'outil directement.
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
RÈGLES DE DESIGN IMPÉRATIVES :
- 8-12 slides, chaque slide a un layout distinct
- Slide 1 : "title" (titre fort + sous-titre accrocheur)
- Slide 2 : "toc" (sommaire numéroté)
- Utilise AU MOINS 2 layouts "diagramme" parmi : "timeline", "process", "metrics", "comparison"
- Thèmes recommandés : architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- Tu DOIS utiliser le thème et le style spécifiés dans la requête de l'utilisateur.
- Points concis (max 100 chars), titres percutants et courts
- JSON strict pour generate_pptx, sans texte hors JSON.`,
en: `You are a world-class visual presentation designer (Manus AI / Beautiful.ai style). You receive note content and must create a professional, modern, visually rich PowerPoint (.pptx) presentation.
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
2. Identifie les types de slides les plus adaptés au contenu réel.
You MUST call the generate_pptx tool. NEVER respond with text — call the tool directly.
═══ ÉTAPE 2 — APPEL generate_slides ═══
Appelle generate_slides avec un objet JSON structuré :
{
"title": "Titre court (6 mots max)",
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
"slides": [ ... tableau de slides ... ]
}
IMPERATIVE DESIGN RULES:
- 8-12 slides, each slide has a distinct layout
- Slide 1: "title" (strong title + catchy subtitle)
- Slide 2: "toc" (numbered table of contents)
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison"
- Recommended themes: architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority
- You MUST use the theme and style specified in the user's request.
- Concise points (max 100 chars), punchy and short titles
- Strict JSON for generate_pptx, no text outside JSON.`,
═══ TYPES DE SLIDES DISPONIBLES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
═══ RÈGLES ═══
- 6 à 12 slides par présentation
- Slide 1 OBLIGATOIREMENT type "title"
- Dernière slide OBLIGATOIREMENT type "summary"
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
- VARIER les types — jamais 2 types identiques consécutifs
- Chaque texte (bullet, description) = 15+ mots, phrase complète
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
- Les données chart doivent refléter les vrais chiffres de la note
═══ THÈMES DISPONIBLES ═══
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
═══ STEP 1 — READ (MANDATORY) ═══
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
2. Identify the best slide types matching the actual content.
═══ STEP 2 — CALL generate_slides ═══
Call generate_slides with a structured JSON object:
{
"title": "Short title (6 words max)",
"theme": "architectural-saas",
"slides": [ ... array of slides ... ]
}
═══ AVAILABLE SLIDE TYPES ═══
1. "title" → { type:"title", title:"...", subtitle:"..." }
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
═══ RULES ═══
- 6 to 12 slides per presentation
- Slide 1 MUST be type "title"
- Last slide MUST be type "summary"
- At least 1 "chart" or "stats" slide if numbers exist in the note
- VARY types — never 2 identical types in a row
- Each text (bullet, description) = 15+ words, complete sentence
- ALL data comes from the note (NEVER invent numbers)
- Chart data must reflect actual numbers from the note
═══ AVAILABLE THEMES ═══
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
},
'task-extractor': {
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
@@ -1102,21 +1164,46 @@ async function executeToolUseAgent(
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Utilise OBLIGATOIREMENT l\'outil generate_excalidraw pour créer le diagramme. Ne réponds pas avec du texte, appelle directement l\'outil.'
: 'IMPORTANT: You MUST use the generate_excalidraw tool to create the diagram. Do NOT respond with text, call the tool directly.'}`
const diagramType = agent.slideTheme || 'auto'
// Map UI diagram type values to internal tool values
const diagramTypeMapping: Record<string, string> = {
'mind_map': 'mindmap',
'org_chart': 'org-chart',
'architecture': 'architecture-cloud',
'process_map': 'process-map',
'logic_flow': 'auto',
}
const rawDiagramType = agent.slideTheme || 'auto'
const diagramType = diagramTypeMapping[rawDiagramType] || rawDiagramType
prompt += `\n\n${lang === 'fr'
? `Type de diagramme imposé : ajoute "type":"${diagramType}" dans le JSON envoyé à generate_excalidraw.`
: `Required diagram type: include "type":"${diagramType}" in the JSON passed to generate_excalidraw.`}`
prompt += `\n\n${lang === 'fr'
? 'Types supportés: auto, flowchart, mindmap, architecture-cloud, org-chart, timeline, process-map. Si "auto", choisis selon le métier et le contenu.'
: 'Supported types: auto, flowchart, mindmap, architecture-cloud, org-chart, timeline, process-map. If "auto", choose according to domain and content.'}`
const diagramStyle = agent.slideStyle === 'austere' || agent.slideStyle === 'sketch-plus' ? agent.slideStyle : 'default'
// Map UI diagram style values to internal tool values
const diagramStyleMapping: Record<string, string> = {
'sketchy': 'sketch-plus',
'draft': 'sketch-plus',
'handwritten': 'sketch-plus',
'minimal': 'austere',
'professional': 'austere',
'soft': 'default',
'polished': 'default',
}
const rawDiagramStyle = agent.slideStyle || 'default'
const diagramStyle = diagramStyleMapping[rawDiagramStyle] || (rawDiagramStyle === 'austere' || rawDiagramStyle === 'sketch-plus' ? rawDiagramStyle : 'default')
prompt += `\n\n${lang === 'fr'
? `Style visuel imposé : ajoute "style":"${diagramStyle}" dans le JSON envoyé à generate_excalidraw.`
: `Visual style required: include "style":"${diagramStyle}" in the JSON passed to generate_excalidraw.`}`
break
}
case 'slide-generator': {
const slideTopic = agent.description || agent.name
const slideTopic = agent.description?.startsWith('template:')
? agent.name
: (agent.description || agent.name)
const slideTemplate = agent.description?.startsWith('template:')
? agent.description.replace('template:', '')
: 'auto'
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = []
@@ -1148,49 +1235,63 @@ async function executeToolUseAgent(
}
}
prompt = lang === 'fr'
? `Crée une présentation PowerPoint professionnelle sur le sujet "${slideTopic}" en utilisant le contenu des notes ci-dessous.`
: `Create a professional PowerPoint presentation about "${slideTopic}" using the content from the notes below.`
? `Crée une présentation professionnelle sur le sujet "${slideTopic}" en utilisant le contenu des notes ci-dessous. Appelle generate_slides avec un objet JSON structuré (title, theme, slides[]).`
: `Create a professional presentation about "${slideTopic}" using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
// Extract image URLs from note content
const extractedImages: Array<{ url: string; noteTitle: string }> = []
if (notes.length > 0) {
const notesContext = notes.map(n => {
// Extract markdown images: ![alt](url) — only external/data URLs (skip relative paths that won't resolve)
const mdMatches = [...n.content.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)]+|data:[^)]+)\)/g)]
for (const m of mdMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
// Extract HTML img tags
const htmlMatches = [...n.content.matchAll(/<img[^>]+src=["'](https?:\/\/[^"']+|data:[^"']+)["']/g)]
for (const m of htmlMatches) {
if (m[1]) extractedImages.push({ url: m[1], noteTitle: n.title || untitled })
}
return `### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 800)}`
}).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source à transformer en slides' : 'Source notes to turn into slides'}:\n\n${notesContext}`
const notesContext = notes.map(n =>
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
).join('\n\n')
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
}
// Inject available images into the prompt
const uniqueImages = extractedImages.slice(0, 6) // max 6 images
if (uniqueImages.length > 0) {
const imgList = uniqueImages.map((img, i) => ` ${i + 1}. "${img.noteTitle}" → ${img.url.substring(0, 120)}`).join('\n')
prompt += `\n\n${lang === 'fr'
? `IMAGES DISPONIBLES (extraites des notes) — utilise-les dans le JSON via le champ "imageUrl" avec le layout "image-content" ou "image-full" :\n${imgList}`
: `AVAILABLE IMAGES (extracted from notes) — use them in the JSON via the "imageUrl" field with layout "image-content" or "image-full":\n${imgList}`}`
// ── Executive Template Structure (HTML-compatible) ──
if (slideTemplate && slideTemplate !== 'auto') {
const templates: Record<string, { fr: string; en: string }> = {
'board-update': {
fr: `Structure imposée "Board Update" (10 slides) :
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
en: `Mandatory "Board Update" structure (10 slides):
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
},
'project-status': {
fr: `Structure imposée "Project Status" (10 slides) :
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
en: `Mandatory "Project Status" structure (10 slides):
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
},
'strategy-review': {
fr: `Structure imposée "Strategy Review" (10 slides) :
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
en: `Mandatory "Strategy Review" structure (10 slides):
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
},
'quarterly-results': {
fr: `Structure imposée "Quarterly Results" (10 slides) :
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
en: `Mandatory "Quarterly Results" structure (10 slides):
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
}
}
const tmpl = templates[slideTemplate]
if (tmpl) {
prompt += `\n\n${tmpl[lang === 'fr' ? 'fr' : 'en']}`
}
}
// ── Density rules ──
prompt += lang === 'fr'
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
prompt += `\n\n${lang === 'fr'
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_pptx. Ne réponds pas avec du texte. Crée 8-12 slides visuelles, commence par "title", puis "toc", intègre AU MOINS 2 layouts diagramme (timeline, process, metrics, ou comparison). Évite les slides avec juste du texte — favorise les layouts visuels.'
: 'IMPORTANT: You MUST call generate_pptx. Do NOT respond with text. Create 8-12 visual slides: start with "title", then "toc", include AT LEAST 2 diagram layouts (timeline, process, metrics, or comparison). Avoid text-only slides — prefer visual layouts.'}`
if (agent.slideTheme) {
prompt += `\n\n${lang === 'fr'
? `Thème imposé par l'utilisateur : "${agent.slideTheme}". Dans le JSON tu DOIS mettre "theme": "${agent.slideTheme}".`
: `User-selected theme: "${agent.slideTheme}". You MUST put "theme": "${agent.slideTheme}" in the JSON.`}`
}
if (agent.slideStyle) {
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. 6-12 slides variées.'
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. 6-12 varied slides.'}`
if (agent.slideTheme && agent.slideTheme !== 'auto') {
prompt += `\n${lang === 'fr'
? `Style visuel imposé : dans le JSON tu DOIS mettre "style": "${agent.slideStyle}". Les valeurs possibles sont: "sharp" (angles nets), "soft" (arrondi standard), "rounded" (très arrondi), "pill" (capsules).`
: `Visual style: you MUST put "style": "${agent.slideStyle}" in the JSON. Values: "sharp" (crisp edges), "soft" (standard rounded), "rounded" (very rounded), "pill" (capsule shapes).`}`
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
}
break
}
@@ -1269,7 +1370,7 @@ async function executeToolUseAgent(
return { success: false, actionId, error: 'Model does not support tool calling' }
}
if (agentType === 'slide-generator' || agentType === 'excalidraw-generator') {
const toolName = agentType === 'slide-generator' ? 'generate_pptx' : 'generate_excalidraw'
const toolName = agentType === 'slide-generator' ? 'generate_slides' : 'generate_excalidraw'
await prisma.agentAction.update({
where: { id: actionId },
data: {
@@ -1301,7 +1402,7 @@ async function executeToolUseAgent(
const scrapedUrls: string[] = []
let specificToolCalled = false
const requiredTool = isFileGenerator
? (agentType === 'slide-generator' ? ['generate_pptx'] : ['generate_excalidraw'])
? (agentType === 'slide-generator' ? ['generate_slides'] : ['generate_excalidraw'])
: null
for (const step of result.steps) {
@@ -1312,7 +1413,7 @@ async function executeToolUseAgent(
existingNoteId = toolResult.output.noteId
}
}
if (step.toolCalls[i].toolName === 'generate_excalidraw' || step.toolCalls[i].toolName === 'generate_slides' || step.toolCalls[i].toolName === 'generate_pptx') {
if (step.toolCalls[i].toolName === 'generate_excalidraw' || step.toolCalls[i].toolName === 'generate_slides') {
const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.success && toolResult.output?.canvasId) {
canvasId = toolResult.output.canvasId as string

View File

@@ -3,6 +3,8 @@ import { cosineSimilarity } from '@/lib/utils'
import { embeddingService } from './embedding.service'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { upsertNoteEmbedding } from '@/lib/embeddings'
export interface NoteConnection {
note1: {
@@ -79,15 +81,7 @@ export class MemoryEchoService {
try {
const embedding = await provider.getEmbeddings(note.content.slice(0, 15000))
if (embedding && embedding.length > 0) {
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt")
VALUES (gen_random_uuid(), $1, $2::vector, now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector`,
note.id,
vecStr
)
await upsertNoteEmbedding(note.id, embedding)
}
} catch {
// Skip this note, continue with others
@@ -131,8 +125,8 @@ export class MemoryEchoService {
// Fetch embeddings separately using raw SQL to avoid deserialization error
const noteIds = notes.map(n => n.id)
const embeddings: Array<{ noteId: string, embedding: any }> = await prisma.$queryRawUnsafe(
`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${noteIds.map(id => `'${id}'`).join(',')})`
const embeddings = noteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
Prisma.sql`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${Prisma.join(noteIds)})`
)
const embeddingMap = new Map(embeddings.map(e => [e.noteId, e.embedding]))
@@ -526,8 +520,8 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
// Fetch all other embeddings
const otherNoteIds = otherNotes.map(n => n.id)
const otherEmbeddings: Array<{ noteId: string, embedding: any }> = await prisma.$queryRawUnsafe(
`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${otherNoteIds.map(id => `'${id}'`).join(',')})`
const otherEmbeddings = otherNoteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
Prisma.sql`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${Prisma.join(otherNoteIds)})`
)
const otherEmbeddingMap = new Map(otherEmbeddings.map(e => [e.noteId, e.embedding]))

View File

@@ -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,

View File

@@ -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}` }
}
},
}),
})

View File

@@ -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) {

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
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)">&#8249;</button>
<div id="nav-dots" style="display:flex;gap:8px;align-items:center;"></div>
<button class="nav-btn" onclick="changeSlide(1)">&#8250;</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>`
}

View 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'
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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)

View File

@@ -0,0 +1,17 @@
import { auth } from '@/auth'
/**
* Retrieves the authenticated user ID or throws an Unauthorized error.
* Use this in Server Actions and API routes to eliminate the repetitive
* `const session = await auth(); if (!session?.user?.id) ...` boilerplate.
*
* @throws {Error} 'Unauthorized' if no valid session exists.
* @returns The authenticated user's ID string.
*/
export async function requireAuth(): Promise<string> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
return session.user.id
}

View File

@@ -0,0 +1,18 @@
import prisma from '@/lib/prisma'
/**
* Upsert a note embedding into the NoteEmbedding table.
* Uses a single SQL UPSERT to avoid race conditions.
* The SQL template is fully static — no user data is interpolated into the query string.
*/
export async function upsertNoteEmbedding(noteId: string, embedding: number[]): Promise<void> {
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = EXCLUDED."embedding", "updatedAt" = now()`,
noteId,
vecStr
)
}

View File

@@ -0,0 +1,41 @@
/**
* Shared Prisma select object for note list queries.
* Excludes the `embedding` field (stored in a separate NoteEmbedding table)
* to avoid loading ~6KB per note in list views.
*/
export const NOTE_LIST_SELECT = {
id: true,
title: true,
content: true,
color: true,
isPinned: true,
isArchived: true,
trashedAt: true,
type: true,
dismissedFromRecent: true,
checkItems: true,
labels: true,
images: true,
illustrationSvg: true,
links: true,
reminder: true,
isReminderDone: true,
reminderRecurrence: true,
reminderLocation: true,
isMarkdown: true,
size: true,
sharedWith: true,
userId: true,
order: true,
notebookId: true,
createdAt: true,
updatedAt: true,
contentUpdatedAt: true,
autoGenerated: true,
aiProvider: true,
aiConfidence: true,
language: true,
languageConfidence: true,
lastAiAnalysis: true,
historyEnabled: true,
} as const

View File

@@ -0,0 +1,67 @@
/**
* Shared types for the Presentation / Slides system.
* Used by both the server-side tool (slides.tool.ts) and the client renderer (slides-renderer.tsx).
*/
export interface SlideChart {
type: 'bar' | 'line' | 'area' | 'pie' | 'radar' | 'waterfall' | 'funnel' | 'combo' | 'stacked-bar' | 'gauge' | 'treemap'
data: Array<Record<string, string | number>>
/** Key in data objects used for the X-axis / labels (defaults to "name") */
xKey?: string
/** Keys for data series. Defaults to all keys except xKey */
yKeys?: string[]
colors?: string[]
showLegend?: boolean
showGrid?: boolean
/** For combo charts: which yKeys to render as lines (others as bars) */
lineKeys?: string[]
/** For gauge: target value (0-100) */
gaugeValue?: number
/** For gauge: label below the value */
gaugeLabel?: string
}
export interface SlideSpec {
title: string
subtitle?: string
/** Bullet points, stat values, card items, etc. */
content: string[]
layout?: 'title' | 'content' | 'section' | 'two-column' | 'cards' | 'stats' | 'quote' | 'toc' | 'summary' | 'image' | 'chart' | 'diagram' | 'timeline' | 'kpi-dashboard' | 'data-table'
imageUrl?: string
/** Speaker notes / talking points for the presenter (2-3 sentences) */
notes?: string
/** Recharts-compatible chart spec — used with layout="chart" */
chart?: SlideChart
/** Mermaid diagram source — used with layout="diagram" */
mermaid?: string
/** react-icons identifier e.g. "FaRocket" — decorative icon */
icon?: string
/** For data-table layout: column headers */
tableHeaders?: string[]
/** For data-table layout: rows of data (each row is array of cell values) */
tableRows?: string[][]
}
/** Executive presentation template types */
export type SlideTemplate = 'auto' | 'board-update' | 'project-status' | 'strategy-review' | 'quarterly-results'
export interface PresentationSpec {
title: string
slides: SlideSpec[]
/** Palette key — one of the PALETTES keys or alias */
theme?: string
/** "sharp" | "brutalist" | "creative" | "rounded" | "pill" — affects border radius */
style?: string
author?: string
/** Executive template used for structure guidance */
template?: SlideTemplate
}
export interface Palette {
primary: string
secondary: string
accent: string
light: string
bg: string
isDark: boolean
}

View File

@@ -0,0 +1,99 @@
/**
* Shared Zod schemas for API route input validation.
* Import the relevant schema in the route handler, call .safeParse(body),
* and return 400 if !result.success.
*/
import { z } from 'zod'
// ─── Shared enums (derived from lib/types.ts) ──────────────────────────────
export const NOTE_COLOR_SCHEMA = z.enum([
'default', 'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray',
])
export const NOTE_TYPE_SCHEMA = z.enum(['text', 'markdown', 'richtext', 'checklist'])
export const NOTE_SIZE_SCHEMA = z.enum(['small', 'medium', 'large'])
export const LABEL_COLOR_SCHEMA = z.enum([
'red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray',
])
// ─── Note schemas ───────────────────────────────────────────────────────────
const CheckItemSchema = z.object({
id: z.string().max(100),
text: z.string().max(5_000),
checked: z.boolean(),
})
/** POST /api/notes */
export const CreateNoteSchema = z.object({
title: z.string().max(500).nullable().optional(),
content: z.string().max(500_000).optional().default(''),
color: NOTE_COLOR_SCHEMA.optional().default('default'),
type: NOTE_TYPE_SCHEMA.optional().default('text'),
size: NOTE_SIZE_SCHEMA.optional().default('small'),
checkItems: z.array(CheckItemSchema).max(500).nullable().optional(),
labels: z.array(z.string().max(200)).max(100).nullable().optional(),
images: z.unknown().nullable().optional(),
notebookId: z.string().max(100).nullable().optional(),
})
/** PUT /api/notes */
export const UpdateNoteSchema = z.object({
id: z.string().min(1).max(100),
title: z.string().max(500).nullable().optional(),
content: z.string().max(500_000).optional(),
color: NOTE_COLOR_SCHEMA.optional(),
type: NOTE_TYPE_SCHEMA.optional(),
size: NOTE_SIZE_SCHEMA.optional(),
checkItems: z.array(CheckItemSchema).max(500).nullable().optional(),
labels: z.array(z.string().max(200)).max(100).nullable().optional(),
isPinned: z.boolean().optional(),
isArchived: z.boolean().optional(),
images: z.unknown().nullable().optional(),
})
/** GET /api/notes query params */
export const GetNotesQuerySchema = z.object({
archived: z.enum(['true', 'false']).optional(),
search: z.string().max(500).optional(),
notebookId: z.string().max(100).optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
})
// ─── Notebook schemas ────────────────────────────────────────────────────────
/** POST /api/notebooks */
export const CreateNotebookSchema = z.object({
name: z.string().min(1, 'Name is required').max(200).trim(),
icon: z.string().max(20).optional(),
color: z.string().max(30).optional(),
parentId: z.string().max(100).nullable().optional(),
})
/** PATCH /api/notebooks/[id] */
export const PatchNotebookSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
icon: z.string().max(20).optional(),
color: z.string().max(30).optional(),
order: z.number().int().min(0).optional(),
trashedAt: z.string().datetime().nullable().optional(),
parentId: z.string().max(100).nullable().optional(),
})
// ─── Label schemas ───────────────────────────────────────────────────────────
/** POST /api/labels */
export const CreateLabelSchema = z.object({
name: z.string().min(1, 'Name is required').max(200).trim(),
color: LABEL_COLOR_SCHEMA.optional(),
notebookId: z.string().min(1).max(100),
})
/** PUT /api/labels/[id] */
export const UpdateLabelSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
color: LABEL_COLOR_SCHEMA.optional(),
})

View File

@@ -533,13 +533,20 @@
"slides": "Generate Slides",
"sectionLabel": "Generation Tools",
"theme": "Theme",
"themeArchitecturalMono": "Architectural Mono",
"themeAuto": "Automatic (AI picks)",
"themeArchitecturalMono": "Architectural Mono",
"themeVibrantTech": "Vibrant Tech",
"themeMinimalSilk": "Minimal Silk",
"style": "Style",
"styleProfessional": "Professional",
"styleCreative": "Creative",
"styleBrutalist": "Brutalist",
"template": "Template",
"templateAuto": "Auto (AI decides)",
"templateBoard": "Board Update",
"templateProject": "Project Status",
"templateStrategy": "Strategy Review",
"templateQuarterly": "Quarterly Results",
"diagram": "Generate Diagram",
"diagramReadyHint": "Convert note into visual flow",
"diagramType": "Diagram Type",
@@ -631,6 +638,7 @@
"errorShort": "Error",
"readyToast": "Ready!",
"downloadFailedToast": "Download failed",
"viewSlidesButton": "View Presentation",
"pptxDownloadButton": "Download .pptx",
"presentationReadyBadge": "Presentation ready",
"openInLabTitle": "Open in Lab",
@@ -2297,7 +2305,22 @@
"exportDefaultNoteTitle": "Synthesis",
"exportOpening": "Opening…",
"ownerBadge": "Owner",
"waveBadge": "Wave {wave}"
"waveBadge": "Wave {wave}",
"exampleSeed1": "Simplify my morning routine",
"exampleSeed2": "Ideas for a creative project this weekend",
"exampleSeed3": "Systems to manage my energy better",
"exampleSeed4": "What I learned this week",
"summarize": "AI Synthesis",
"summaryError": "Could not generate synthesis",
"regenerateSummary": "Regenerate",
"exportAsNote": "Export as note",
"exportModalTitle": "Session summary",
"viewCanvas": "Canvas",
"viewList": "List",
"ideasCount": "ideas",
"star": "Star idea",
"unstar": "Unstar idea",
"renameSession": "Rename session"
},
"byokSettings": {
"title": "Your API keys (BYOK)",

View File

@@ -539,13 +539,20 @@
"slides": "Générer Slides",
"sectionLabel": "Outils de Génération",
"theme": "Thème",
"themeArchitecturalMono": "Architectural Mono",
"themeAuto": "Automatique (IA choisit)",
"themeArchitecturalMono": "Architectural Mono",
"themeVibrantTech": "Tech vibrant",
"themeMinimalSilk": "Soie minimaliste",
"style": "Style",
"styleProfessional": "Professionnel",
"styleCreative": "Créatif",
"styleBrutalist": "Brutaliste",
"template": "Gabarit",
"templateAuto": "Auto (l'IA décide)",
"templateBoard": "Mise à jour direction",
"templateProject": "Statut de projet",
"templateStrategy": "Revue stratégique",
"templateQuarterly": "Résultats trimestriels",
"diagram": "Générer Diagramme",
"diagramReadyHint": "Convertir en flux visuel",
"diagramType": "Type de Diagramme",
@@ -638,6 +645,7 @@
"readyToast": "Prêt !",
"downloadFailedToast": "Échec du téléchargement",
"pptxDownloadButton": "Télécharger .pptx",
"viewSlidesButton": "Voir la présentation",
"presentationReadyBadge": "Présentation prête",
"openInLabTitle": "Ouvrir dans le Lab",
"inlineSummaryMarkdown": "**Résumé :**",
@@ -2303,7 +2311,22 @@
"exportDefaultNoteTitle": "Synthèse",
"exportOpening": "Ouverture…",
"ownerBadge": "Propriétaire",
"waveBadge": "Vague {wave}"
"waveBadge": "Vague {wave}",
"exampleSeed1": "Comment simplifier ma routine matinale ?",
"exampleSeed2": "Idées pour un projet créatif ce weekend",
"exampleSeed3": "Systèmes pour mieux gérer mon énergie",
"exampleSeed4": "Ce que j'ai appris cette semaine",
"summarize": "Synthèse IA",
"summaryError": "Impossible de générer la synthèse",
"regenerateSummary": "Regénérer",
"exportAsNote": "Exporter en note",
"exportModalTitle": "Bilan de session",
"viewCanvas": "Canevas",
"viewList": "Liste",
"ideasCount": "idées",
"star": "Mettre en favori",
"unstar": "Retirer des favoris",
"renameSession": "Renommer la session"
},
"byokSettings": {
"title": "Vos clés API (BYOK)",

View File

@@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
// Enable standalone output for Docker
output: 'standalone',
// Pre-existing TS errors in 3rd-party import paths (next/cache cookies, AI SDK v6 maxSteps)
// are false positives that don't affect runtime — skip type-check at build time
typescript: {
@@ -23,6 +24,35 @@ const nextConfig: NextConfig = {
},
]
},
async headers() {
return [
{
// Apply to all routes
source: '/(.*)',
headers: [
// Prevent clickjacking
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
// Prevent MIME-type sniffing
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// Limit referrer info to same-origin
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
// Disable browser features not needed by the app
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
// HSTS — enforce HTTPS for 1 year (prod only; harmless in dev)
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
// Legacy XSS filter for older browsers
{ key: 'X-XSS-Protection', value: '1; mode=block' },
],
},
]
},
async redirects() {
return [

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,13 @@
"@dnd-kit/utilities": "^3.2.2",
"@excalidraw/excalidraw": "^0.18.0",
"@mozilla/readability": "^0.6.0",
"@napi-rs/canvas": "^0.1.65",
"@nivo/bar": "^0.99.0",
"@nivo/core": "^0.99.0",
"@nivo/heatmap": "^0.99.0",
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@nivo/radar": "^0.99.0",
"@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -95,6 +102,7 @@
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
"marked": "^18.0.3",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
@@ -105,7 +113,10 @@
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-force-graph-2d": "^1.29.1",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"recharts": "^3.8.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
@@ -120,8 +131,7 @@
"tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4",
"vazirmatn": "^33.0.3",
"zod": "^4.3.5",
"@napi-rs/canvas": "^0.1.65"
"zod": "^4.3.5"
},
"devDependencies": {
"@playwright/test": "^1.57.0",

View File

@@ -183,6 +183,8 @@ model Note {
labelRelations Label[] @relation("LabelToNote")
attachments NoteAttachment[]
brainstormNoteRefs BrainstormNoteRef[]
outgoingLinks NoteLink[] @relation("SourceLinks")
incomingLinks NoteLink[] @relation("TargetLinks")
@@index([isPinned])
@@index([isArchived])
@@ -325,6 +327,20 @@ model NoteEmbedding {
@@index([noteId])
}
model NoteLink {
id String @id @default(cuid())
sourceNoteId String
targetNoteId String
contextSnippet String?
createdAt DateTime @default(now())
sourceNote Note @relation("SourceLinks", fields: [sourceNoteId], references: [id], onDelete: Cascade)
targetNote Note @relation("TargetLinks", fields: [targetNoteId], references: [id], onDelete: Cascade)
@@unique([sourceNoteId, targetNoteId])
@@index([sourceNoteId])
@@index([targetNoteId])
}
model Agent {
id String @id @default(cuid())
name String
@@ -502,6 +518,7 @@ model BrainstormIdea {
status String @default("active")
positionX Float?
positionY Float?
isStarred Boolean @default(false)
createdAt DateTime @default(now())
createdBy String?
createdByType String? @default("ai")

View File

@@ -0,0 +1,14 @@
title | left
----------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Brainstorm Canvas : un outil temporaire pour organiser ses idées avant de les intégrer à ses notes | <h2>Le problème que ça résout</h2><p>Tu as 200 notes. Tu veux explorer une idée nouvelle (ou approfondir une existante). Aujourd'hui, tu fais quoi ? Tu ouvres un doc vierge et tu réfléchis seul. Le br
Implémentation des liens bidirectionnels et vue graphique dans un système de notes | <p><strong>Innovation conceptuelle</strong> : lintelligence artificielle réinterprète vos notes à travers divers <strong>prismes danalyse</strong> (ingénieur, financier, client, sceptique ou optimis
Roadmap Memento : Nouvelles Fonctionnalités | <hr> +
| <h2>documentType: &#39;evolution-roadmap&#39; +
| project: Memento (Keep) +
| author: Sepehr RAMEZANI &amp; Copilot +
| date: &#39;2026-04-29&#39; +
| status: &#39;proposal&#39; +
| version: &#39;1.0&#39;</h2> +
| <h1>M
(3 rows)

View File

@@ -0,0 +1,5 @@
id | agentId | status | createdAt | age_sec | log
---------------------------+---------------------------+---------+-------------------------+---------+-----
cmph2rw8q000r6u0g8rq1jz4o | cmph2rw5q000p6u0g0iv1jjk9 | running | 2026-05-22 15:29:14.234 | 182 |
(1 row)

View File

@@ -27,6 +27,7 @@ export interface BrainstormIdea {
status: 'active' | 'dismissed' | 'converted'
positionX: number | null
positionY: number | null
isStarred: boolean
createdBy: string | null
createdByType: 'ai' | 'human' | null
createdAt: Date