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 ## 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). Documentation technique : [brainstorm-documentation.md](../../memento-note/docs/brainstorm-documentation.md).
### Parcours type ---
1. Saisir une **idée graine** (ou partir dune note existante). ### Démarrer une session
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.
### 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: {} }, 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 ═══ // ═══ LABELS ═══
{ {
name: 'create_label', name: 'create_label',
@@ -538,25 +595,32 @@ export function registerTools(server, prisma) {
} }
case 'search_notes': { case 'search_notes': {
const safeQuery = (args.query || '').replace(/'/g, "''"); const query = args.query || ''
const userClause = uid ? `AND "userId" = '${uid}'` : ''; const params = [query]
const notebookClause = args.notebookId ? `AND "notebookId" = '${args.notebookId.replace(/'/g, "''")}'` : ''; let paramIdx = 1
const ftsRows = await prisma.$queryRawUnsafe(` const userClause = uid ? `AND "userId" = $${++paramIdx}` : ''
SELECT id, title, content, color, type, "isPinned", "isArchived", if (uid) params.push(uid)
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", "isMarkdown", size, "createdAt", "updatedAt", "notebookId",
images, labels, "checkItems", reminder, "isReminderDone" images, labels, "checkItems", reminder, "isReminderDone"
FROM "Note" FROM "Note"
WHERE "tsv" @@ plainto_tsquery('simple', '${safeQuery}') WHERE "tsv" @@ plainto_tsquery('simple', $1)
AND "trashedAt" IS NULL AND "trashedAt" IS NULL
AND "isArchived" = ${args.includeArchived ? 'true' : 'false'} AND "isArchived" = ${args.includeArchived ? 'true' : 'false'}
${userClause} ${userClause}
${notebookClause} ${notebookClause}
ORDER BY ts_rank("tsv", plainto_tsquery('simple', '${safeQuery}')) DESC ORDER BY ts_rank("tsv", plainto_tsquery('simple', $1)) DESC
LIMIT ${DEFAULT_SEARCH_LIMIT} LIMIT ${DEFAULT_SEARCH_LIMIT}`,
`); ...params
)
return textResult(ftsRows.map(parseNoteLightweight)); return textResult(ftsRows.map(parseNoteLightweight))
} }
case 'move_note': { case 'move_note': {
@@ -950,6 +1014,127 @@ export function registerTools(server, prisma) {
return textResult({ count: reminders.length, reminders }); 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: default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
} }

View File

@@ -3,7 +3,7 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react' import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { motion } from 'motion/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 { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n' 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"> <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 justify-between items-end"> <div className="flex items-start gap-3 sm:flex-row sm:justify-between sm:items-end">
<div className="space-y-1"> {/* Hamburger mobile */}
<h1 className="font-memento-serif text-4xl font-medium tracking-tight text-foreground"> <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')} {t('agents.myAgents')}
</h1> </h1>
<p className="text-sm text-muted-foreground font-light"> <p className="text-sm text-muted-foreground font-light">
@@ -246,13 +254,13 @@ export function AgentsPageClient({
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-8 border-b border-border pt-4"> <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-8"> <div className="flex items-center gap-4 sm:gap-8 overflow-x-auto">
{typeFilterOptions.map((opt, i) => ( {typeFilterOptions.map((opt, i) => (
<button <button
key={opt.value} key={opt.value}
onClick={() => setTypeFilter(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' typeFilter === opt.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/60'
}`} }`}
> >
@@ -266,7 +274,7 @@ export function AgentsPageClient({
</button> </button>
))} ))}
</div> </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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/60" />
<input <input
type="text" type="text"
@@ -279,7 +287,7 @@ export function AgentsPageClient({
</div> </div>
</header> </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 ? ( {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"> <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" /> <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' 'use client'
import { Menu } from 'lucide-react'
import { SettingsNav } from '@/components/settings' import { SettingsNav } from '@/components/settings'
export default function SettingsLayout({ export default function SettingsLayout({
@@ -9,21 +10,30 @@ export default function SettingsLayout({
}) { }) {
return ( return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper"> <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"> <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="space-y-4"> <div className="flex items-start gap-3">
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium"> <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 Paramètres
</h1> </h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60"> <p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
Configuration & Préférences Configuration & Préférences
</p> </p>
</div> </div>
</div>
<SettingsNav /> <SettingsNav />
</header> </header>
<div className="flex-1 overflow-y-auto"> <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} {children}
</div> </div>
</div> </div>

View File

@@ -10,7 +10,7 @@ export default function SupportPage() {
return ( return (
<div className="container mx-auto py-10 max-w-4xl"> <div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10"> <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')} {t('support.title')}
</h1> </h1>
<p className="text-muted-foreground text-lg"> <p className="text-muted-foreground text-lg">

View File

@@ -13,6 +13,7 @@ import {
Search, Search,
Clock, Clock,
AlertCircle, AlertCircle,
Menu,
} from 'lucide-react' } from 'lucide-react'
import { Note, Notebook } from '@/lib/types' import { Note, Notebook } from '@/lib/types'
import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes' import { restoreNote, permanentDeleteNote, emptyTrash } from '@/app/actions/notes'
@@ -139,16 +140,26 @@ export function TrashClient({
return ( return (
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-[#1a1a1a]"> <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"> <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"> <div className="flex items-center justify-between gap-3">
<div className="space-y-1"> <div className="flex items-center gap-3 min-w-0">
<h1 className="text-4xl font-memento-serif font-medium text-foreground flex items-center gap-4"> {/* 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" /> {t('sidebar.trash')} <Trash2 size={28} className="text-rose-400 opacity-40" />
</h1> </h1>
<p className="text-[10px] text-muted-foreground font-bold uppercase tracking-[0.3em] opacity-60"> <p className="text-[10px] text-muted-foreground font-bold uppercase tracking-[0.3em] opacity-60">
{t('trash.autoDelete30')} {t('trash.autoDelete30')}
</p> </p>
</div> </div>
</div>{/* end flex items-center gap-3 */}
{items.length > 0 && ( {items.length > 0 && (
<button <button
@@ -161,24 +172,24 @@ export function TrashClient({
)} )}
</div> </div>
<div className="flex items-center gap-6"> <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6">
<div className="group relative flex-1 max-w-xl"> <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} /> <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 <input
type="text" type="text"
placeholder={t('common.search')} placeholder={t('common.search')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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>
<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 => ( {(['all', 'notes', 'notebooks'] as FilterType[]).map(type => (
<button <button
key={type} key={type}
onClick={() => setFilterType(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'}`} ${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')} {type === 'all' ? t('trash.filterAll') : type === 'notes' ? t('nav.notes') : t('nav.notebooks')}
@@ -188,9 +199,9 @@ export function TrashClient({
</div> </div>
</header> </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 ? ( {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"> <AnimatePresence mode="popLayout">
{items.map(item => { {items.map(item => {
const daysLeft = getDaysRemaining(item.deletedAt) const daysLeft = getDaysRemaining(item.deletedAt)
@@ -281,7 +292,7 @@ export function TrashClient({
)} )}
</main> </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" /> <AlertCircle size={14} className="text-muted-foreground" />
<p className="text-[10px] text-muted-foreground font-medium uppercase tracking-widest"> <p className="text-[10px] text-muted-foreground font-medium uppercase tracking-widest">
{t('trash.notebookRestoreHint')} {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 maxSteps: number
}> = { }> = {
'slide-generator': { 'slide-generator': {
role: 'Crée une présentation PowerPoint professionnelle et visuelle à partir du contenu de la note fournie.', 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_pptx'], tools: ['note_search', 'note_read', 'generate_slides'],
maxSteps: 8, maxSteps: 6,
}, },
'excalidraw-generator': { 'excalidraw-generator': {
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.', 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 userId = session.user.id
const body = await req.json() const body = await req.json()
const { noteId, type, theme, style, language } = body as { const { noteId, type, theme, style, language, template } = body as {
noteId: string noteId: string
type: GenerateType type: GenerateType
theme?: string theme?: string
style?: string style?: string
language?: string language?: string
template?: string
} }
if (!noteId || !type || !TYPE_DEFAULTS[type]) { if (!noteId || !type || !TYPE_DEFAULTS[type]) {
@@ -56,10 +57,14 @@ export async function POST(req: NextRequest) {
let role = defaults.role let role = defaults.role
if (isEn) { if (isEn) {
if (type === 'slide-generator') { 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 { } else {
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.' 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' const agentName = type === 'slide-generator'
@@ -71,13 +76,14 @@ export async function POST(req: NextRequest) {
name: agentName, name: agentName,
type, type,
role, role,
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
tools: JSON.stringify(defaults.tools), tools: JSON.stringify(defaults.tools),
maxSteps: defaults.maxSteps, maxSteps: defaults.maxSteps,
frequency: 'one-shot', frequency: 'one-shot',
isEnabled: true, isEnabled: true,
sourceNoteIds: JSON.stringify([noteId]), sourceNoteIds: JSON.stringify([noteId]),
targetNotebookId: note.notebookId ?? undefined, targetNotebookId: note.notebookId ?? undefined,
slideTheme: theme ?? 'vibrant_tech', slideTheme: theme ?? 'keynote',
slideStyle: style ?? 'soft', slideStyle: style ?? 'soft',
userId, userId,
}, },
@@ -118,7 +124,7 @@ export async function GET(req: NextRequest) {
const action = await prisma.agentAction.findFirst({ const action = await prisma.agentAction.findFirst({
where: { agentId }, where: { agentId },
orderBy: { createdAt: 'desc' }, 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) { if (!action) {
@@ -126,6 +132,19 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ status: 'running' }) 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) // If success, find canvasId from the related canvas (result stores canvas id)
let canvasId: string | null = null let canvasId: string | null = null
let noteId: 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 } 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( function buildExpandPromptV2(
parentTitle: string, parentTitle: string,
parentDesc: 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. 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( export async function POST(

View File

@@ -161,6 +161,7 @@ export async function PATCH(
const updates: Record<string, any> = {} const updates: Record<string, any> = {}
if (typeof body.isPublic === 'boolean') updates.isPublic = body.isPublic if (typeof body.isPublic === 'boolean') updates.isPublic = body.isPublic
if (typeof body.guestCanEdit === 'boolean') updates.guestCanEdit = body.guestCanEdit 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) { if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }) 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 { function buildPromptV2(seedIdea: string, classifiedNotes: ClassifiedNote[], locale?: string): string {
const supportNotes = classifiedNotes.filter(n => n.category === 'SUPPORT') const supportNotes = classifiedNotes.filter(n => n.category === 'SUPPORT')
const tensionNotes = classifiedNotes.filter(n => n.category === 'TENSION') 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. 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[] { 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. - **Lab**: Experimental AI tools for data analysis and deeper insights.
## Available tools ## 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. 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. - 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: { 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.`, 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. 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 ## 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. - 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: { fa: {
contextWithNotes: `## یادداشت‌های کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشت‌های بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید.`, 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 { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { auth } from '@/auth' import { auth } from '@/auth'
import { CreateLabelSchema } from '@/lib/validators'
const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray']; const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
@@ -12,11 +14,10 @@ export async function GET(request: NextRequest) {
} }
try { try {
const searchParams = request.nextUrl.searchParams const notebookId = request.nextUrl.searchParams.get('notebookId')
const notebookId = searchParams.get('notebookId')
// Build where clause // userId is always required — prevents reading another user's labels by notebookId
const where: any = {} const where: Prisma.LabelWhereInput = { userId: session.user.id }
if (notebookId === 'null' || notebookId === '') { if (notebookId === 'null' || notebookId === '') {
// Get labels without a notebook (backward compatibility) // Get labels without a notebook (backward compatibility)
@@ -24,9 +25,6 @@ export async function GET(request: NextRequest) {
} else if (notebookId) { } else if (notebookId) {
// Get labels for a specific notebook // Get labels for a specific notebook
where.notebookId = notebookId where.notebookId = notebookId
} else {
// Get all labels for the user
where.userId = session.user.id
} }
const labels = await prisma.label.findMany({ const labels = await prisma.label.findMany({
@@ -61,21 +59,14 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { name, color, notebookId } = body const parsed = CreateLabelSchema.safeParse(body)
if (!parsed.success) {
if (!name || typeof name !== 'string') {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Label name is required' }, { success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
}
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
{ success: false, error: 'notebookId is required' },
{ status: 400 } { status: 400 }
) )
} }
const { name, color, notebookId } = parsed.data
// Verify notebook ownership // Verify notebook ownership
const notebook = await prisma.notebook.findUnique({ const notebook = await prisma.notebook.findUnique({

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { auth } from '@/auth' import { auth } from '@/auth'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { PatchNotebookSchema } from '@/lib/validators'
async function getDescendantIds(notebookId: string): Promise<string[]> { async function getDescendantIds(notebookId: string): Promise<string[]> {
const ids: string[] = [] const ids: string[] = []
@@ -28,7 +30,14 @@ export async function PATCH(
try { try {
const { id } = await params const { id } = await params
const body = await request.json() 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({ const existing = await prisma.notebook.findUnique({
where: { id }, where: { id },
@@ -76,12 +85,12 @@ export async function PATCH(
} }
} }
const updateData: any = {} const updateData: Prisma.NotebookUpdateInput = {}
if (name !== undefined) updateData.name = name.trim() if (name !== undefined) updateData.name = name
if (icon !== undefined) updateData.icon = icon if (icon !== undefined) updateData.icon = icon
if (color !== undefined) updateData.color = color if (color !== undefined) updateData.color = color
if (order !== undefined) updateData.order = order 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 (parentId !== undefined) updateData.parentId = parentId
if (trashedAt !== undefined) { if (trashedAt !== undefined) {

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { auth } from '@/auth' import { auth } from '@/auth'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { CreateNotebookSchema } from '@/lib/validators'
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4'] const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧'] const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
@@ -63,11 +65,14 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { name, icon, color, parentId } = body const parsed = CreateNotebookSchema.safeParse(body)
if (!parsed.success) {
if (!name || typeof name !== 'string') { return NextResponse.json(
return NextResponse.json({ success: false, error: 'Notebook name is required' }, { status: 400 }) { success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 }
)
} }
const { name, icon, color, parentId } = parsed.data
if (parentId) { if (parentId) {
const parent = await prisma.notebook.findFirst({ const parent = await prisma.notebook.findFirst({
@@ -78,9 +83,7 @@ export async function POST(request: NextRequest) {
} }
} }
const whereClause: any = { userId: session.user.id } const whereClause: Prisma.NotebookWhereInput = { userId: session.user.id, parentId: parentId ?? null }
if (parentId) whereClause.parentId = parentId
else whereClause.parentId = null
const highestOrder = await prisma.notebook.findFirst({ const highestOrder = await prisma.notebook.findFirst({
where: whereClause, 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) 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({ return NextResponse.json({
success: true, success: true,
data: parseNote(note) 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 // DELETE /api/notes/[id] - Delete a note
export async function DELETE( export async function DELETE(
request: NextRequest, 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 { NextRequest, NextResponse } from 'next/server'
import { Prisma } from '@prisma/client'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { auth } from '@/auth' import { auth } from '@/auth'
import { parseNote } from '@/lib/utils' import { parseNote } from '@/lib/utils'
import { CreateNoteSchema, UpdateNoteSchema, GetNotesQuerySchema } from '@/lib/validators'
// GET /api/notes - Get all notes // GET /api/notes - Get all notes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -14,13 +16,23 @@ export async function GET(request: NextRequest) {
} }
try { try {
const searchParams = request.nextUrl.searchParams const rawQuery = {
const includeArchived = searchParams.get('archived') === 'true' archived: request.nextUrl.searchParams.get('archived') ?? undefined,
const search = searchParams.get('search') search: request.nextUrl.searchParams.get('search') ?? undefined,
const notebookId = searchParams.get('notebookId') notebookId: request.nextUrl.searchParams.get('notebookId') ?? undefined,
const limit = searchParams.get('limit') ? parseInt(searchParams.get('limit')!) : 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, userId: session.user.id,
trashedAt: null trashedAt: null
} }
@@ -75,25 +87,25 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { title, content, color, type, checkItems, labels, images } = body const parsed = CreateNoteSchema.safeParse(body)
if (!parsed.success) {
if (!content && type !== 'checklist') {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Content is required' }, { success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 } { status: 400 }
) )
} }
const { title, content, color, type, checkItems, labels, images } = parsed.data
const note = await prisma.note.create({ const note = await prisma.note.create({
data: { data: {
userId: session.user.id, userId: session.user.id,
title: title || null, title: title ?? null,
content: content || '', content: content ?? '',
color: color || 'default', color: color ?? 'default',
type: type || 'text', type: type ?? 'text',
checkItems: checkItems ?? null, checkItems: checkItems != null ? JSON.stringify(checkItems) : null,
labels: labels ?? null, labels: labels != null ? JSON.stringify(labels) : null,
images: images ?? null, images: images != null ? JSON.stringify(images) : null,
} }
}) })
@@ -122,34 +134,16 @@ export async function PUT(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body const parsed = UpdateNoteSchema.safeParse(body)
if (!parsed.success) {
if (!id) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Note ID is required' }, { success: false, error: 'Invalid request body', details: parsed.error.flatten() },
{ status: 400 } { status: 400 }
) )
} }
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = parsed.data
const existingNote = await prisma.note.findUnique({ const updateData: Prisma.NoteUpdateInput = {}
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 = {}
if (title !== undefined) updateData.title = title if (title !== undefined) updateData.title = title
if (content !== undefined) updateData.content = content if (content !== undefined) updateData.content = content
@@ -161,10 +155,21 @@ export async function PUT(request: NextRequest) {
if (isArchived !== undefined) updateData.isArchived = isArchived if (isArchived !== undefined) updateData.isArchived = isArchived
if (images !== undefined) updateData.images = images ?? null if (images !== undefined) updateData.images = images ?? null
const note = await prisma.note.update({ let note
where: { id }, try {
note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData 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({ return NextResponse.json({
success: true, success: true,
@@ -200,29 +205,21 @@ export async function DELETE(request: NextRequest) {
) )
} }
const existingNote = await prisma.note.findUnique({ try {
where: { id } await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
}) })
} catch (e) {
if (!existingNote) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Note not found' }, { success: false, error: 'Note not found or access denied' },
{ status: 404 } { 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({ return NextResponse.json({
success: true, success: true,
message: 'Note moved to trash' message: 'Note moved to trash'

View File

@@ -310,7 +310,7 @@ export function AgentDetailView({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="min-h-full flex flex-col" 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"> <div className="flex items-center justify-between max-w-5xl mx-auto">
<button <button
onClick={onBack} onClick={onBack}
@@ -348,7 +348,7 @@ export function AgentDetailView({
</div> </div>
</header> </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"> <section className="space-y-8">
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<div className="space-y-4"> <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"> <div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
<MessageSquare size={18} /> <MessageSquare size={18} />
</div> </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> </div>
)} )}

View File

@@ -19,6 +19,13 @@ import {
Users, Users,
Globe, Globe,
Lock, Lock,
Menu,
Star,
Sparkles,
List,
LayoutGrid,
X,
Edit2,
} from 'lucide-react' } from 'lucide-react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
@@ -36,6 +43,9 @@ import {
useAddManualIdea, useAddManualIdea,
useBrainstormActivity, useBrainstormActivity,
useUpdateBrainstormSettings, useUpdateBrainstormSettings,
useStarIdea,
useSummarizeBrainstorm,
useRenameBrainstormSession,
} from '@/hooks/use-brainstorm' } from '@/hooks/use-brainstorm'
import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket' import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket'
import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors' import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors'
@@ -106,12 +116,20 @@ export function BrainstormPage() {
const joinMutation = useJoinBrainstorm() const joinMutation = useJoinBrainstorm()
const addManualIdea = useAddManualIdea(activeSessionId || '') const addManualIdea = useAddManualIdea(activeSessionId || '')
const { data: activities } = useBrainstormActivity(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 [impactToast, setImpactToast] = useState<{ notesEnriched: number; notesMarkedDry: number } | null>(null)
const [exportError, setExportError] = useState<string | null>(null) const [exportError, setExportError] = useState<string | null>(null)
const [exportToast, setExportToast] = useState<{ noteTitle: string; notebookName: string } | null>(null) const [exportToast, setExportToast] = useState<{ noteTitle: string; notebookName: string } | null>(null)
const [convertToast, setConvertToast] = useState<{ noteTitle: string; noteId: 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 [remoteMove, setRemoteMove] = useState<{ ideaId: string; x: number; y: number; _seq: number } | null>(null)
const [playbackIdeas, setPlaybackIdeas] = useState<any[] | 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 canvasContainerRef = useRef<HTMLDivElement>(null)
const moveSeq = useRef(0) const moveSeq = useRef(0)
@@ -260,11 +278,35 @@ export function BrainstormPage() {
addManualIdea.mutate({ title, parentIdeaId, locale: language }) addManualIdea.mutate({ title, parentIdeaId, locale: language })
}, [addManualIdea, 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 const isGenerating = createBrainstorm.isPending || expandIdea.isPending
return ( return (
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden"> <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 <div
className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]" className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
style={{ style={{
@@ -275,7 +317,15 @@ export function BrainstormPage() {
/> />
<div className="max-w-4xl mx-auto relative"> <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 <motion.div
animate={{ rotate: isGenerating ? 360 : 0 }} animate={{ rotate: isGenerating ? 360 : 0 }}
transition={{ transition={{
@@ -283,12 +333,12 @@ export function BrainstormPage() {
duration: 20, duration: 20,
ease: 'linear', 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} /> <Wind size={28} />
</motion.div> </motion.div>
<div className="flex-1"> <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')} {t('brainstorm.title')}
</h1> </h1>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
@@ -302,8 +352,8 @@ export function BrainstormPage() {
{session && !isGuest && ( {session && !isGuest && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={handleExport} onClick={handleSummarize}
disabled={exportBrainstorm.isPending} 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" 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'} title={t('brainstorm.export') || 'Export'}
> >
@@ -375,7 +425,7 @@ export function BrainstormPage() {
onChange={(e) => setSeedInput(e.target.value)} onChange={(e) => setSeedInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm()} onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm()}
placeholder={t('brainstorm.placeholder') || 'Enter a concept to unfold...'} 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 <button
onClick={() => handleStartBrainstorm()} onClick={() => handleStartBrainstorm()}
@@ -413,6 +463,52 @@ export function BrainstormPage() {
</motion.div> </motion.div>
)} )}
</AnimatePresence> </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>
</div> </div>
@@ -429,6 +525,48 @@ export function BrainstormPage() {
<div className="absolute inset-0 flex items-center justify-center"> <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 className="w-8 h-8 border-2 border-foreground/20 border-t-foreground rounded-full animate-spin" />
</div> </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 ? ( ) : session ? (
<WaveCanvas <WaveCanvas
session={session} session={session}
@@ -507,11 +645,16 @@ export function BrainstormPage() {
<AnimatePresence> <AnimatePresence>
{selectedIdea && ( {selectedIdea && (
<motion.div <motion.div
initial={{ x: '100%' }} initial={{ y: '100%', x: 0 }}
animate={{ x: 0 }} animate={{ y: 0, x: 0 }}
exit={{ x: '100%' }} exit={{ y: '100%', x: 0 }}
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)]" 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="p-8 flex-1 overflow-y-auto">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<div <div
@@ -531,6 +674,16 @@ export function BrainstormPage() {
{t('brainstorm.noteCreated') || 'Note Created'} {t('brainstorm.noteCreated') || 'Note Created'}
</span> </span>
)} )}
{canEdit && (
<button
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 <button
onClick={() => setSelectedIdeaId(null)} onClick={() => setSelectedIdeaId(null)}
className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground" className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground"
@@ -699,8 +852,8 @@ export function BrainstormPage() {
<div className="w-px flex-1 bg-border/40" /> <div className="w-px flex-1 bg-border/40" />
<div className="flex flex-col gap-3 overflow-y-auto px-2"> <div className="flex flex-col gap-3 overflow-y-auto px-2">
{sessions?.map((s) => ( {sessions?.map((s) => (
<div key={s.id} className="relative group/session">
<button <button
key={s.id}
onClick={() => setActiveSessionId(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 ${ 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 activeSessionId === s.id
@@ -713,6 +866,16 @@ export function BrainstormPage() {
> >
{s.seedIdea.charAt(0).toUpperCase()} {s.seedIdea.charAt(0).toUpperCase()}
</button> </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>
<div className="w-px h-12 bg-border/40" /> <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> <AnimatePresence>
{impactToast && ( {impactToast && (
<motion.div <motion.div

View File

@@ -50,6 +50,7 @@ import { getNotebookIcon } from '@/lib/notebook-icon'
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector' import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog' import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
import { scrapePageText } from '@/app/actions/scrape' import { scrapePageText } from '@/app/actions/scrape'
import { PersonasPanel } from '@/components/personas-panel'
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -210,11 +211,13 @@ export function ContextualAIChat({
// Generate slides / diagram state // Generate slides / diagram state
const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null) const [generateLoading, setGenerateLoading] = useState<'slides' | 'diagram' | null>(null)
const [generateProgress, setGenerateProgress] = useState(0)
const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null) const [generateResult, setGenerateResult] = useState<GenerateResult | null>(null)
const [customLangInput, setCustomLangInput] = useState('') const [customLangInput, setCustomLangInput] = useState('')
// Generation options // Generation options
const [slideTheme, setSlideTheme] = useState('architectural_mono') const [slideTheme, setSlideTheme] = useState('auto')
const [slideStyle, setSlideStyle] = useState('professional') const [slideStyle, setSlideStyle] = useState('professional')
const [slideTemplate, setSlideTemplate] = useState('auto')
const [diagramType, setDiagramType] = useState('logic_flow') const [diagramType, setDiagramType] = useState('logic_flow')
const [diagramStyle, setDiagramStyle] = useState('polished') const [diagramStyle, setDiagramStyle] = useState('polished')
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false) const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
@@ -358,6 +361,24 @@ export function ContextualAIChat({
// ── Generate slides / diagram ──────────────────────────────────────────────── // ── Generate slides / diagram ────────────────────────────────────────────────
const generatePollRef = useRef<ReturnType<typeof setInterval> | null>(null) 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') => { const handleGenerate = async (type: 'slides' | 'diagram') => {
if (!noteId) { if (!noteId) {
@@ -366,6 +387,7 @@ export function ContextualAIChat({
} }
setGenerateLoading(type) setGenerateLoading(type)
setGenerateResult(null) setGenerateResult(null)
startProgressBar()
const toastId = mToast.loading( const toastId = mToast.loading(
type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'), type === 'slides' ? t('ai.generateSlidesLoading') : t('ai.generateDiagramLoading'),
@@ -382,6 +404,7 @@ export function ContextualAIChat({
theme: type === 'slides' ? slideTheme : diagramType, theme: type === 'slides' ? slideTheme : diagramType,
style: type === 'slides' ? slideStyle : diagramStyle, style: type === 'slides' ? slideStyle : diagramStyle,
language: language === 'fr' ? 'French' : 'English', language: language === 'fr' ? 'French' : 'English',
template: type === 'slides' ? slideTemplate : undefined,
}), }),
}) })
const data = await res.json() const data = await res.json()
@@ -394,7 +417,18 @@ export function ContextualAIChat({
const { agentId } = data as { agentId: string } const { agentId } = data as { agentId: string }
if (generatePollRef.current) clearInterval(generatePollRef.current) if (generatePollRef.current) clearInterval(generatePollRef.current)
let pollCount = 0
const MAX_POLLS = 200 // 200 × 3s = 10 min safety timeout
generatePollRef.current = setInterval(async () => { 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 { try {
const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`) const pollRes = await fetch(`/api/agents/run-for-note?agentId=${agentId}`)
const poll = await pollRes.json() const poll = await pollRes.json()
@@ -402,12 +436,14 @@ export function ContextualAIChat({
if (poll.status === 'success') { if (poll.status === 'success') {
clearInterval(generatePollRef.current!) clearInterval(generatePollRef.current!)
generatePollRef.current = null generatePollRef.current = null
finishProgressBar()
setGenerateLoading(null) setGenerateLoading(null)
setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId }) setGenerateResult({ type, canvasId: poll.canvasId, noteId: poll.noteId })
mToast.success(t('ai.readyToast'), { id: toastId }) mToast.success(t('ai.readyToast'), { id: toastId })
} else if (poll.status === 'failure') { } else if (poll.status === 'failure') {
clearInterval(generatePollRef.current!) clearInterval(generatePollRef.current!)
generatePollRef.current = null generatePollRef.current = null
finishProgressBar()
setGenerateLoading(null) setGenerateLoading(null)
mToast.error(poll.error || t('ai.errorShort'), { id: toastId }) mToast.error(poll.error || t('ai.errorShort'), { id: toastId })
} }
@@ -415,6 +451,7 @@ export function ContextualAIChat({
}, 3000) }, 3000)
} catch { } catch {
mToast.error(t('ai.errorShort'), { id: toastId }) mToast.error(t('ai.errorShort'), { id: toastId })
finishProgressBar()
setGenerateLoading(null) 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"> <div className="w-12 h-12 rounded-full border border-dashed border-concrete/10 flex items-center justify-center">
<MessageSquare size={18} /> <MessageSquare size={18} />
</div> </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> </div>
)} )}
@@ -980,9 +1017,20 @@ export function ContextualAIChat({
<div className="space-y-1.5"> <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> <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"> <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="auto">{t('ai.generate.themeAuto')}</option>
<option value="vibrant_tech">{t('ai.generate.themeVibrantTech')}</option> <option value="Architectural SaaS">Architectural SaaS SaaS, Produit</option>
<option value="minimal_silk">{t('ai.generate.themeMinimalSilk')}</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> </select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -994,10 +1042,36 @@ export function ContextualAIChat({
</select> </select>
</div> </div>
</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"> <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')}</>} {generateLoading === 'slides' ? <Loader2 size={14} className="animate-spin" /> : <><Presentation size={14} className="opacity-80" /> {t('ai.generating')}</>}
</button> </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 && ( {generateResult?.type === 'slides' && generateResult.canvasId && (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
@@ -1018,35 +1092,15 @@ export function ContextualAIChat({
<ExternalLink size={12} /> <ExternalLink size={12} />
</a> </a>
</div> </div>
<button <a
onClick={async () => { href={`/lab?id=${generateResult.canvasId}`}
try { target="_blank"
const res = await fetch(`/api/canvas?id=${generateResult.canvasId}`) rel="noopener noreferrer"
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'))
}
}}
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" 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} /> <Presentation size={13} />
{t('ai.pptxDownloadButton')} {t('ai.viewSlidesButton')}
</button> </a>
</motion.div> </motion.div>
)} )}
@@ -1129,6 +1183,8 @@ export function ContextualAIChat({
</div> </div>
</div> </div>
{/* ── Personas IA ── */}
<PersonasPanel noteTitle={noteTitle} noteContent={noteContent} />
</motion.div> </motion.div>
)} )}

View File

@@ -10,7 +10,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
import { MemoryEchoNotification } from '@/components/memory-echo-notification' import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button' 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 { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh' import { useRefresh } from '@/lib/use-refresh'
import { useReminderCheck } from '@/hooks/use-reminder-check' import { useReminderCheck } from '@/hooks/use-reminder-check'
@@ -81,7 +81,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [showSortMenu, setShowSortMenu] = useState(false) const [showSortMenu, setShowSortMenu] = useState(false)
const [showInlineSearch, setShowInlineSearch] = useState(false) const [showInlineSearch, setShowInlineSearch] = useState(false)
const [inlineSearchQuery, setInlineSearchQuery] = useState('') const [inlineSearchQuery, setInlineSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const inlineSearchRef = useRef<HTMLInputElement>(null) const inlineSearchRef = useRef<HTMLInputElement>(null)
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const notesRef = useRef(notes) const notesRef = useRef(notes)
notesRef.current = notes notesRef.current = notes
const { refreshKey, triggerRefresh } = useNoteRefresh() const { refreshKey, triggerRefresh } = useNoteRefresh()
@@ -98,13 +100,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [isTagsExpanded, setIsTagsExpanded] = useState(false) const [isTagsExpanded, setIsTagsExpanded] = useState(false)
const [tagSearchQuery, setTagSearchQuery] = useState('') 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) // Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
useEffect(() => { useEffect(() => {
if (searchParams.get('forceList') === '1') { 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="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div <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 className="flex justify-between items-start gap-3">
<div> {/* 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 && ( {currentNotebook && notebookPath.length > 0 && (
<div <div
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60" 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> </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
? currentNotebook.name ? currentNotebook.name
: searchParams.get('shared') === '1' : searchParams.get('shared') === '1'
@@ -527,7 +530,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
{/* Inline search — toggles an input within the toolbar */} {/* Inline search — toggles an input within the toolbar */}
{showInlineSearch ? ( {showInlineSearch ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{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" /> <Search size={14} className="text-muted-foreground shrink-0" />
)}
<input <input
ref={inlineSearchRef} ref={inlineSearchRef}
autoFocus autoFocus
@@ -536,6 +543,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
onChange={e => { onChange={e => {
const q = e.target.value const q = e.target.value
setInlineSearchQuery(q) setInlineSearchQuery(q)
setIsSearching(true)
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
searchDebounceRef.current = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
if (q.trim()) { if (q.trim()) {
params.set('search', q) params.set('search', q)
@@ -543,6 +553,8 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
params.delete('search') params.delete('search')
} }
router.push(`/home?${params.toString()}`) router.push(`/home?${params.toString()}`)
setIsSearching(false)
}, 300)
}} }}
onBlur={() => { onBlur={() => {
if (!inlineSearchQuery) { if (!inlineSearchQuery) {
@@ -551,28 +563,32 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}} }}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
setShowInlineSearch(false) setShowInlineSearch(false)
setInlineSearchQuery('') setInlineSearchQuery('')
setIsSearching(false)
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
params.delete('search') params.delete('search')
router.push(`/home?${params.toString()}`) router.push(`/home?${params.toString()}`)
} }
}} }}
placeholder={t('search.placeholder')} 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 && ( {inlineSearchQuery && (
<button <button
onClick={() => { onClick={() => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current)
setShowInlineSearch(false) setShowInlineSearch(false)
setInlineSearchQuery('') setInlineSearchQuery('')
setIsSearching(false)
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
params.delete('search') params.delete('search')
router.push(`/home?${params.toString()}`) router.push(`/home?${params.toString()}`)
}} }}
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<span className="text-[11px]">×</span> <X size={12} />
</button> </button>
)} )}
</div> </div>
@@ -714,7 +730,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)} )}
</div> </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 ? ( {isLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div> <div className="text-center py-8 text-muted-foreground">{t('general.loading')}</div>
) : ( ) : (
@@ -760,7 +776,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
)} )}
</div> </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"> <p className="text-[11px] text-muted-foreground uppercase tracking-[0.2em] font-medium">
Memento &mdash; {new Date().getFullYear()} Memento &mdash; {new Date().getFullYear()}
</p> </p>

View File

@@ -4,17 +4,23 @@ import dynamic from 'next/dynamic'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { toast } from 'sonner' 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 { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types' import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types' import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css' import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation'
const Excalidraw = dynamic( const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw, async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false } { 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 { interface CanvasBoardProps {
initialData?: string initialData?: string
canvasId?: string canvasId?: string
@@ -22,32 +28,37 @@ interface CanvasBoardProps {
} }
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string } 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): { function parseCanvasScene(initialData?: string): {
slides: SlidesPayload | null
pptx: PptxPayload | null pptx: PptxPayload | null
elements: readonly ExcalidrawElement[] elements: readonly ExcalidrawElement[]
files: BinaryFiles files: BinaryFiles
} { } {
if (!initialData) { if (!initialData) {
return { pptx: null, elements: [], files: {} } return { slides: null, pptx: null, elements: [], files: {} }
} }
try { try {
const parsed = JSON.parse(initialData) 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) { 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)) { 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) { if (parsed && parsed.elements) {
const files: BinaryFiles = const files: BinaryFiles =
parsed.files && typeof parsed.files === 'object' ? parsed.files : {} 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) { } catch (e) {
console.error('[CanvasBoard] Failed to parse canvas data:', 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 }) { 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) { export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false) const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved') const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
@@ -173,6 +331,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
}, 2000) }, 2000)
} }
if (scene.slides) {
return <SlidesViewer data={scene.slides} name={name} canvasId={localId} />
}
if (scene.pptx) { if (scene.pptx) {
return <PptxViewer data={scene.pptx} name={name} /> 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 { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl' import { pl } from 'date-fns/locale/pl'
import { LabelBadge } from './label-badge' import { LabelBadge } from './label-badge'
import DOMPurify from 'isomorphic-dompurify'
import { NoteImages } from './note-images' import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist' import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions' import { NoteActions } from './note-actions'
@@ -196,11 +197,7 @@ export const NoteCard = memo(function NoteCard({
const sanitizedHtml = useMemo(() => { const sanitizedHtml = useMemo(() => {
if (note.type !== 'richtext' || !note.content) return '' if (note.type !== 'richtext' || !note.content) return ''
if (typeof window !== 'undefined') { return DOMPurify.sanitize(note.content)
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('isomorphic-dompurify').sanitize(note.content)
}
return note.content
}, [note.type, note.content]) }, [note.type, note.content])
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => { 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 { DocumentQAOverlay } from '@/components/document-qa-overlay'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { useState } from 'react' import { useState } from 'react'
import { WikilinksBacklinksPanel } from '@/components/wikilinks-backlinks-panel'
interface NoteEditorFullPageProps { interface NoteEditorFullPageProps {
onClose: () => void onClose: () => void
@@ -114,6 +115,9 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
onCountChange={setAttachmentsCount} onCountChange={setAttachmentsCount}
triggerUpload={uploadTrigger} triggerUpload={uploadTrigger}
/> />
{/* WikiLinks backlinks */}
<WikilinksBacklinksPanel noteId={note.id} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -91,7 +91,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
if (mode === 'fullPage') { if (mode === 'fullPage') {
return ( 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 <button
onClick={onClose} onClick={onClose}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity" 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 + '/') const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
return ( 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) => ( {tabs.map((tab) => (
<Link <Link
key={tab.id} key={tab.id}
href={tab.href} 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 }} style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
> >
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span> <span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>

View File

@@ -29,6 +29,7 @@ import {
PinOff, PinOff,
Sparkles, Sparkles,
Home, Home,
Network,
} from 'lucide-react' } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@@ -182,6 +183,7 @@ function SidebarCarnetItem({
isExpanded, isExpanded,
toggleExpand, toggleExpand,
hasChildNotebooks, hasChildNotebooks,
hasActiveDescendant,
}: { }: {
carnet: { id: string; name: string; initial: string; isPrivate?: boolean } carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
isActive: boolean isActive: boolean
@@ -201,6 +203,7 @@ function SidebarCarnetItem({
isExpanded: boolean isExpanded: boolean
toggleExpand: () => void toggleExpand: () => void
hasChildNotebooks?: boolean hasChildNotebooks?: boolean
hasActiveDescendant?: boolean
}) { }) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const isRtl = language === 'fa' || language === 'ar' const isRtl = language === 'fa' || language === 'ar'
@@ -287,6 +290,9 @@ function SidebarCarnetItem({
{carnet.name} {carnet.name}
</span> </span>
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />} {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>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0"> <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 [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [isDark, setIsDark] = useState(false) 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(() => { useEffect(() => {
setIsDark(document.documentElement.classList.contains('dark')) setIsDark(document.documentElement.classList.contains('dark'))
@@ -631,22 +650,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
} catch {} } 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) => { const togglePin = useCallback((id: string) => {
setPinnedIds(prev => { setPinnedIds(prev => {
const next = new Set(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)) return desc.some(c => currentNotebookId === c.id || hasDescendant(c.id))
} }
const hasActiveDescendant = hasDescendant(notebook.id) const hasActiveDescendant = hasDescendant(notebook.id)
// A notebook stays expanded if: manually expanded, has active descendant, OR is pinned // A notebook stays expanded if: manually expanded OR is pinned
const isExpanded = expandedIds.has(notebook.id) || hasActiveDescendant || pinnedIds.has(notebook.id) const isExpanded = expandedIds.has(notebook.id) || pinnedIds.has(notebook.id)
return ( return (
<motion.div key={notebook.id}> <motion.div key={notebook.id}>
@@ -784,7 +787,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
toggleExpand(notebook.id) toggleExpand(notebook.id)
} else { } else {
handleCarnetClick(notebook.id) handleCarnetClick(notebook.id)
if (!expandedIds.has(notebook.id)) toggleExpand(notebook.id)
} }
}} }}
onNoteClick={handleNoteClick} onNoteClick={handleNoteClick}
@@ -802,6 +804,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
isExpanded={isExpanded} isExpanded={isExpanded}
toggleExpand={() => toggleExpand(notebook.id)} toggleExpand={() => toggleExpand(notebook.id)}
hasChildNotebooks={children.length > 0} hasChildNotebooks={children.length > 0}
hasActiveDescendant={hasActiveDescendant}
/> />
</div> </div>
{isExpanded && renderCarnetTree(notebook.id, level + 1)} {isExpanded && renderCarnetTree(notebook.id, level + 1)}
@@ -812,10 +815,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
return ( 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 <aside
className={cn( className={cn(
'hidden h-full min-h-0 w-72 shrink-0 flex-col lg:w-80 md:flex', // Mobile: fixed overlay, slide in/out
'border-e border-border/40 bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none', '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 className
)} )}
> >
@@ -1145,7 +1167,25 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
)} )}
</Link> </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>
</div> </div>
</aside> </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, 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': { '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 : ═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
- 8-12 slides, chaque slide a un layout distinct 1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
- Slide 1 : "title" (titre fort + sous-titre accrocheur) 2. Identifie les types de slides les plus adaptés au contenu réel.
- 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.
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: ═══ TYPES DE SLIDES DISPONIBLES ═══
- 8-12 slides, each slide has a distinct layout 1. "title" → { type:"title", title:"...", subtitle:"..." }
- Slide 1: "title" (strong title + catchy subtitle) 2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
- Slide 2: "toc" (numbered table of contents) 3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
- Use AT LEAST 2 "diagram" layouts from: "timeline", "process", "metrics", "comparison" 4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
- Recommended themes: architectural_mono, minimal_silk, vibrant_tech, platinum_white_gold, business_authority 5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
- You MUST use the theme and style specified in the user's request. 6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
- Concise points (max 100 chars), punchy and short titles 7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
- Strict JSON for generate_pptx, no text outside JSON.`, 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': { '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. 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' 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 : 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.'}` : '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' prompt += `\n\n${lang === 'fr'
? `Type de diagramme imposé : ajoute "type":"${diagramType}" dans le JSON envoyé à generate_excalidraw.` ? `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.`}` : `Required diagram type: include "type":"${diagramType}" in the JSON passed to generate_excalidraw.`}`
prompt += `\n\n${lang === 'fr' 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.' ? '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.'}` : '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' prompt += `\n\n${lang === 'fr'
? `Style visuel imposé : ajoute "style":"${diagramStyle}" dans le JSON envoyé à generate_excalidraw.` ? `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.`}` : `Visual style required: include "style":"${diagramStyle}" in the JSON passed to generate_excalidraw.`}`
break break
} }
case 'slide-generator': { 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 untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US' const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
let notes: any[] = [] let notes: any[] = []
@@ -1148,49 +1235,63 @@ async function executeToolUseAgent(
} }
} }
prompt = lang === 'fr' prompt = lang === 'fr'
? `Crée une présentation PowerPoint professionnelle sur le sujet "${slideTopic}" en utilisant le contenu des notes ci-dessous.` ? `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 PowerPoint presentation about "${slideTopic}" using the content from the notes below.` : `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) { if (notes.length > 0) {
const notesContext = notes.map(n => { const notesContext = notes.map(n =>
// Extract markdown images: ![alt](url) — only external/data URLs (skip relative paths that won't resolve) `### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
const mdMatches = [...n.content.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)]+|data:[^)]+)\)/g)] ).join('\n\n')
for (const m of mdMatches) { prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
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}`
} }
// Inject available images into the prompt // ── Executive Template Structure (HTML-compatible) ──
const uniqueImages = extractedImages.slice(0, 6) // max 6 images if (slideTemplate && slideTemplate !== 'auto') {
if (uniqueImages.length > 0) { const templates: Record<string, { fr: string; en: string }> = {
const imgList = uniqueImages.map((img, i) => ` ${i + 1}. "${img.noteTitle}" → ${img.url.substring(0, 120)}`).join('\n') 'board-update': {
prompt += `\n\n${lang === 'fr' fr: `Structure imposée "Board Update" (10 slides) :
? `IMAGES DISPONIBLES (extraites des notes) — utilise-les dans le JSON via le champ "imageUrl" avec le layout "image-content" ou "image-full" :\n${imgList}` 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`,
: `AVAILABLE IMAGES (extracted from notes) — use them in the JSON via the "imageUrl" field with layout "image-content" or "image-full":\n${imgList}`}` 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' 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 : 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_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.'}` : 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. 6-12 varied slides.'}`
if (agent.slideTheme) {
prompt += `\n\n${lang === 'fr' if (agent.slideTheme && agent.slideTheme !== 'auto') {
? `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) {
prompt += `\n${lang === 'fr' 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).` ? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
: `Visual style: you MUST put "style": "${agent.slideStyle}" in the JSON. Values: "sharp" (crisp edges), "soft" (standard rounded), "rounded" (very rounded), "pill" (capsule shapes).`}` : `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
} }
break break
} }
@@ -1269,7 +1370,7 @@ async function executeToolUseAgent(
return { success: false, actionId, error: 'Model does not support tool calling' } return { success: false, actionId, error: 'Model does not support tool calling' }
} }
if (agentType === 'slide-generator' || agentType === 'excalidraw-generator') { 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({ await prisma.agentAction.update({
where: { id: actionId }, where: { id: actionId },
data: { data: {
@@ -1301,7 +1402,7 @@ async function executeToolUseAgent(
const scrapedUrls: string[] = [] const scrapedUrls: string[] = []
let specificToolCalled = false let specificToolCalled = false
const requiredTool = isFileGenerator const requiredTool = isFileGenerator
? (agentType === 'slide-generator' ? ['generate_pptx'] : ['generate_excalidraw']) ? (agentType === 'slide-generator' ? ['generate_slides'] : ['generate_excalidraw'])
: null : null
for (const step of result.steps) { for (const step of result.steps) {
@@ -1312,7 +1413,7 @@ async function executeToolUseAgent(
existingNoteId = toolResult.output.noteId 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] const toolResult = step.toolResults?.[i]
if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.success && toolResult.output?.canvasId) { if (toolResult && typeof toolResult.output === 'object' && toolResult.output?.success && toolResult.output?.canvasId) {
canvasId = toolResult.output.canvasId as string canvasId = toolResult.output.canvasId as string

View File

@@ -3,6 +3,8 @@ import { cosineSimilarity } from '@/lib/utils'
import { embeddingService } from './embedding.service' import { embeddingService } from './embedding.service'
import { getSystemConfig } from '@/lib/config' import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { Prisma } from '@prisma/client'
import { upsertNoteEmbedding } from '@/lib/embeddings'
export interface NoteConnection { export interface NoteConnection {
note1: { note1: {
@@ -79,15 +81,7 @@ export class MemoryEchoService {
try { try {
const embedding = await provider.getEmbeddings(note.content.slice(0, 15000)) const embedding = await provider.getEmbeddings(note.content.slice(0, 15000))
if (embedding && embedding.length > 0) { if (embedding && embedding.length > 0) {
const vecStr = `[${embedding.join(',')}]` await upsertNoteEmbedding(note.id, embedding)
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
)
} }
} catch { } catch {
// Skip this note, continue with others // Skip this note, continue with others
@@ -131,8 +125,8 @@ export class MemoryEchoService {
// Fetch embeddings separately using raw SQL to avoid deserialization error // Fetch embeddings separately using raw SQL to avoid deserialization error
const noteIds = notes.map(n => n.id) const noteIds = notes.map(n => n.id)
const embeddings: Array<{ noteId: string, embedding: any }> = await prisma.$queryRawUnsafe( const embeddings = noteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${noteIds.map(id => `'${id}'`).join(',')})` 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])) 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 // Fetch all other embeddings
const otherNoteIds = otherNotes.map(n => n.id) const otherNoteIds = otherNotes.map(n => n.id)
const otherEmbeddings: Array<{ noteId: string, embedding: any }> = await prisma.$queryRawUnsafe( const otherEmbeddings = otherNoteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${otherNoteIds.map(id => `'${id}'`).join(',')})` 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])) 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) 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 { return {
success: true, canvasId: canvas.id, canvasName: canvas.name, success: true, canvasId: canvas.id, canvasName: canvas.name,
elementCount: elements.length, elementCount: elements.length,

View File

@@ -1,10 +1,11 @@
/** /**
* Note CRUD Tools * Note CRUD Tools
* note_create, note_read, note_update * note_create, note_read, note_update, note_find_and_update
*/ */
import { tool } from 'ai' import { tool } from 'ai'
import { z } from 'zod' import { z } from 'zod'
import { Prisma } from '@prisma/client'
import { toolRegistry } from './registry' import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { markdownToHtml } from '@/lib/markdown-to-html' 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). * When webOnly is true, only web tools are included (no note access).
*/ */
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> { 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 // Add web tools only when user toggled web search AND config is present
if (ctx.webSearch) { 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 { z } from 'zod'
import { toolRegistry } from './registry' import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { buildPresentationHTML } from './slides-html-builder'
interface SlideSpec { const slideSchema = z.discriminatedUnion('type', [
title: string z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
subtitle?: string z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
content: 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() }),
layout?: 'title' | 'content' | 'section' | 'two-column' | 'cards' | 'stats' | 'quote' | 'toc' | 'summary' | 'image' z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
imageUrl?: string z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
notes?: 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() }),
interface PresentationSpec { 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() }) }),
title: string 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() }),
slides: SlideSpec[] z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
theme?: string z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.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 }
}
toolRegistry.register({ toolRegistry.register({
name: 'generate_slides', 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, isInternal: true,
buildTool: (ctx) => buildTool: (ctx) =>
tool({ 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: Available slide types:
{ - "title": title, subtitle (opening slide with particles)
"title": "Presentation Title", - "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
"theme": "vibrant_tech", - "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
"slides": [ - "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
{ "title": "Title", "subtitle": "Subtitle", "content": [], "layout": "title" }, - "table": title, headers[], rows[][] (data table)
{ "title": "Sommaire", "content": ["Section 1", "Section 2"], "layout": "toc" }, - "cards": title, cards[{title, description}] (3-6 info cards)
{ "title": "Key Points", "content": ["Point 1", "Point 2"], "layout": "content" }, - "timeline": title, events[{date, title, description}] (chronological events)
{ "title": "Features", "content": ["Feature A: desc", "Feature B: desc"], "layout": "cards" }, - "quote": quote, author, context (citation with analysis)
{ "title": "Metrics", "content": ["99% - Uptime", "50K - Users"], "layout": "stats" }, - "comparison": title, left{title, points[], score}, right{...} (A vs B)
{ "title": "Introduction", "content": ["01"], "subtitle": "Topic", "layout": "section" }, - "equation": title, equations[{latex, label}], explanation (math formulas)
{ "title": "A great quote.", "subtitle": "- Author", "layout": "quote" }, - "image": title, url, caption (image slide)
{ "title": "Summary", "content": ["Point 1", "Point 2"], "layout": "summary" } - "summary": title, items[] (conclusion with checkmarks)
]
}
THEMES: modern_wellness, business_authority, nature_outdoors, vintage_academic, soft_creative, bohemian, vibrant_tech, craft_artisan, tech_night, education_charts, forest_eco, elegant_fashion, art_food, luxury_mystery, pure_tech_blue, coastal_coral, vibrant_orange_mint, platinum_white_gold
LAYOUTS: title, toc, content, section, two-column, cards, stats, quote, summary, image
RULES: RULES:
- First slide MUST be "title" - 6-12 slides per presentation
- Second slide should be "toc" - First slide MUST be type "title"
- Use "section" for dividers (content[0]=section number like "01") - Last slide MUST be type "summary"
- Use "cards" for feature lists (3-6 items) - Include at least 1 "chart" or "stats" slide
- Use "stats" for numbers (format: "NUMBER - LABEL") - Use VARIED types — never 2 identical types in a row
- Use "quote" for quotes (title=quote, subtitle=attribution) - All text content must come from the source notes (never invent data)
- Use "summary" for closing summary - Each bullet/card must be a real sentence (15+ words, not generic)`,
- Use "two-column" for comparisons
- 5-12 slides, vary layouts, no repeats consecutively`,
inputSchema: z.object({ inputSchema: z.object({
title: z.string().describe('Title for the presentation'), title: z.string().describe('Short presentation title (6 words max)'),
slides: z.string().describe('JSON presentation specification'), 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 { try {
console.log('[Slides Tool] INPUT title:', title) console.log('[Slides Tool] Building presentation:', title, '| Slides:', slides.length, '| Theme:', theme)
console.log('[Slides Tool] INPUT slides (first 500 chars):', slides?.substring(0, 500))
let spec: PresentationSpec const html = buildPresentationHTML({ title, theme, slides: slides as any })
try {
const parsed = JSON.parse(slides)
console.log('[Slides Tool] JSON parsed OK. slides count:', parsed.slides?.length, 'theme:', parsed.theme, 'title:', parsed.title)
if (parsed.slides && Array.isArray(parsed.slides) && parsed.slides.length > 0) {
spec = {
title: parsed.title || title || 'Presentation',
theme: parsed.theme || 'vibrant_tech',
style: parsed.style,
slides: parsed.slides.map((s: any) => ({
title: String(s.title || '').substring(0, 200),
subtitle: s.subtitle ? String(s.subtitle).substring(0, 300) : undefined,
content: Array.isArray(s.content) ? s.content.map((c: any) => String(c).substring(0, 500)).slice(0, 12) : [],
layout: ['title', 'content', 'section', 'two-column', 'cards', 'stats', 'quote', 'toc', 'summary', 'image'].includes(s.layout) ? s.layout : undefined,
imageUrl: s.imageUrl ? String(s.imageUrl).substring(0, 500) : undefined,
notes: s.notes ? String(s.notes).substring(0, 1000) : undefined,
})),
}
} else {
console.log('[Slides Tool] No slides array in JSON, falling back to text parse')
spec = parseSlidesFromText(slides)
}
} catch (parseErr) {
console.log('[Slides Tool] JSON parse failed, falling back to text parse:', parseErr)
spec = parseSlidesFromText(slides)
}
console.log('[Slides Tool] Spec:', JSON.stringify({ title: spec.title, theme: spec.theme, style: spec.style, slideCount: spec.slides.length, layouts: spec.slides.map(s => s.layout) }))
if (spec.slides.length === 0) {
console.log('[Slides Tool] ERROR: No slides provided')
return { success: false, error: 'No slides provided' }
}
const html = buildRevealHtml(spec)
console.log('[Slides Tool] HTML generated. Length:', html.length, '| Start:', html.substring(0, 120))
const canvas = await prisma.canvas.create({ const canvas = await prisma.canvas.create({
data: { data: {
name: title || spec.title || 'Presentation', name: title || 'Présentation',
data: JSON.stringify({ data: JSON.stringify({
type: 'slides', type: 'slides',
title: spec.title, title: title || 'Présentation',
theme: spec.theme,
slideCount: spec.slides.length,
html, html,
slideCount: slides.length,
theme: theme || 'architectural-saas',
spec: { title, theme, slides },
}), }),
userId: ctx.userId, 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 { return {
success: true, canvasId: canvas.id, canvasName: canvas.name, success: true,
slideCount: spec.slides.length, theme: spec.theme, canvasId: canvas.id,
message: `Presentation created with ${spec.slides.length} slides. Open in browser to view.`, canvasName: canvas.name,
slideCount: slides.length,
message: `Presentation "${canvas.name}" created with ${slides.length} slides.`,
} }
} catch (e: any) { } catch (e: any) {
console.error('[Slides Tool] FATAL:', e) 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,6 +533,7 @@
"slides": "Generate Slides", "slides": "Generate Slides",
"sectionLabel": "Generation Tools", "sectionLabel": "Generation Tools",
"theme": "Theme", "theme": "Theme",
"themeAuto": "Automatic (AI picks)",
"themeArchitecturalMono": "Architectural Mono", "themeArchitecturalMono": "Architectural Mono",
"themeVibrantTech": "Vibrant Tech", "themeVibrantTech": "Vibrant Tech",
"themeMinimalSilk": "Minimal Silk", "themeMinimalSilk": "Minimal Silk",
@@ -540,6 +541,12 @@
"styleProfessional": "Professional", "styleProfessional": "Professional",
"styleCreative": "Creative", "styleCreative": "Creative",
"styleBrutalist": "Brutalist", "styleBrutalist": "Brutalist",
"template": "Template",
"templateAuto": "Auto (AI decides)",
"templateBoard": "Board Update",
"templateProject": "Project Status",
"templateStrategy": "Strategy Review",
"templateQuarterly": "Quarterly Results",
"diagram": "Generate Diagram", "diagram": "Generate Diagram",
"diagramReadyHint": "Convert note into visual flow", "diagramReadyHint": "Convert note into visual flow",
"diagramType": "Diagram Type", "diagramType": "Diagram Type",
@@ -631,6 +638,7 @@
"errorShort": "Error", "errorShort": "Error",
"readyToast": "Ready!", "readyToast": "Ready!",
"downloadFailedToast": "Download failed", "downloadFailedToast": "Download failed",
"viewSlidesButton": "View Presentation",
"pptxDownloadButton": "Download .pptx", "pptxDownloadButton": "Download .pptx",
"presentationReadyBadge": "Presentation ready", "presentationReadyBadge": "Presentation ready",
"openInLabTitle": "Open in Lab", "openInLabTitle": "Open in Lab",
@@ -2297,7 +2305,22 @@
"exportDefaultNoteTitle": "Synthesis", "exportDefaultNoteTitle": "Synthesis",
"exportOpening": "Opening…", "exportOpening": "Opening…",
"ownerBadge": "Owner", "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": { "byokSettings": {
"title": "Your API keys (BYOK)", "title": "Your API keys (BYOK)",

View File

@@ -539,6 +539,7 @@
"slides": "Générer Slides", "slides": "Générer Slides",
"sectionLabel": "Outils de Génération", "sectionLabel": "Outils de Génération",
"theme": "Thème", "theme": "Thème",
"themeAuto": "Automatique (IA choisit)",
"themeArchitecturalMono": "Architectural Mono", "themeArchitecturalMono": "Architectural Mono",
"themeVibrantTech": "Tech vibrant", "themeVibrantTech": "Tech vibrant",
"themeMinimalSilk": "Soie minimaliste", "themeMinimalSilk": "Soie minimaliste",
@@ -546,6 +547,12 @@
"styleProfessional": "Professionnel", "styleProfessional": "Professionnel",
"styleCreative": "Créatif", "styleCreative": "Créatif",
"styleBrutalist": "Brutaliste", "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", "diagram": "Générer Diagramme",
"diagramReadyHint": "Convertir en flux visuel", "diagramReadyHint": "Convertir en flux visuel",
"diagramType": "Type de Diagramme", "diagramType": "Type de Diagramme",
@@ -638,6 +645,7 @@
"readyToast": "Prêt !", "readyToast": "Prêt !",
"downloadFailedToast": "Échec du téléchargement", "downloadFailedToast": "Échec du téléchargement",
"pptxDownloadButton": "Télécharger .pptx", "pptxDownloadButton": "Télécharger .pptx",
"viewSlidesButton": "Voir la présentation",
"presentationReadyBadge": "Présentation prête", "presentationReadyBadge": "Présentation prête",
"openInLabTitle": "Ouvrir dans le Lab", "openInLabTitle": "Ouvrir dans le Lab",
"inlineSummaryMarkdown": "**Résumé :**", "inlineSummaryMarkdown": "**Résumé :**",
@@ -2303,7 +2311,22 @@
"exportDefaultNoteTitle": "Synthèse", "exportDefaultNoteTitle": "Synthèse",
"exportOpening": "Ouverture…", "exportOpening": "Ouverture…",
"ownerBadge": "Propriétaire", "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": { "byokSettings": {
"title": "Vos clés API (BYOK)", "title": "Vos clés API (BYOK)",

View File

@@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
// Enable standalone output for Docker // Enable standalone output for Docker
output: 'standalone', output: 'standalone',
// Pre-existing TS errors in 3rd-party import paths (next/cache cookies, AI SDK v6 maxSteps) // 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 // are false positives that don't affect runtime — skip type-check at build time
typescript: { typescript: {
@@ -24,6 +25,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() { async redirects() {
return [ return [
{ {

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,13 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@excalidraw/excalidraw": "^0.18.0", "@excalidraw/excalidraw": "^0.18.0",
"@mozilla/readability": "^0.6.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", "@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -95,6 +102,7 @@
"katex": "^0.16.27", "katex": "^0.16.27",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"marked": "^18.0.3", "marked": "^18.0.3",
"mermaid": "^11.15.0",
"motion": "^12.38.0", "motion": "^12.38.0",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
@@ -105,7 +113,10 @@
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "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", "react-markdown": "^10.1.0",
"recharts": "^3.8.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -120,8 +131,7 @@
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tinyld": "^1.3.4", "tinyld": "^1.3.4",
"vazirmatn": "^33.0.3", "vazirmatn": "^33.0.3",
"zod": "^4.3.5", "zod": "^4.3.5"
"@napi-rs/canvas": "^0.1.65"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",

View File

@@ -183,6 +183,8 @@ model Note {
labelRelations Label[] @relation("LabelToNote") labelRelations Label[] @relation("LabelToNote")
attachments NoteAttachment[] attachments NoteAttachment[]
brainstormNoteRefs BrainstormNoteRef[] brainstormNoteRefs BrainstormNoteRef[]
outgoingLinks NoteLink[] @relation("SourceLinks")
incomingLinks NoteLink[] @relation("TargetLinks")
@@index([isPinned]) @@index([isPinned])
@@index([isArchived]) @@index([isArchived])
@@ -325,6 +327,20 @@ model NoteEmbedding {
@@index([noteId]) @@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 { model Agent {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -502,6 +518,7 @@ model BrainstormIdea {
status String @default("active") status String @default("active")
positionX Float? positionX Float?
positionY Float? positionY Float?
isStarred Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
createdBy String? createdBy String?
createdByType String? @default("ai") 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' status: 'active' | 'dismissed' | 'converted'
positionX: number | null positionX: number | null
positionY: number | null positionY: number | null
isStarred: boolean
createdBy: string | null createdBy: string | null
createdByType: 'ai' | 'human' | null createdByType: 'ai' | 'human' | null
createdAt: Date createdAt: Date