fix: images, couverture, slash, agents, wizard, Stripe et admin

- Collage/accès images: ownership path userId + meta legacy, 403 non caché
- Couverture note: explicite (choix/aucune), plus d’auto depuis le corps
- Éditeur: suppression image TipTap, sync immédiate, toolbar réordonnée
- Slash: listes par titre (plus d’index), keywords nettoyés
- Agents: nextRun à la réactivation, cron sans stampede null
- Wizard: titres courts + mode Expert plus robuste
- Stripe checkout hosted fiable; admin métriques live; dark mode IA/settings
- Indexation notes courtes + test unit aligné
This commit is contained in:
Antigravity
2026-07-16 16:58:07 +00:00
parent 4fe31ebc99
commit 45297da333
27 changed files with 1302 additions and 412 deletions

View File

@@ -1,58 +1,184 @@
import { getUsers } from '@/app/actions/admin'
import { AdminMetrics } from '@/components/admin-metrics'
import { Users, Activity, Database, Zap } from 'lucide-react'
import { Users, Activity, Database, Zap, CreditCard, Bot } from 'lucide-react'
import prisma from '@/lib/prisma'
import Link from 'next/link'
async function getAdminDashboardStats() {
const now = new Date()
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const [
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
recentUsers,
] = await Promise.all([
prisma.user.count().catch(() => 0),
prisma.note.count({ where: { trashedAt: null } }).catch(() => 0),
prisma.notebook.count().catch(() => 0),
prisma.agent.count({ where: { isEnabled: true } }).catch(() => 0),
prisma.agentAction.count({ where: { createdAt: { gte: dayAgo } } }).catch(() => 0),
prisma.subscription.count({
where: { tier: { in: ['PRO', 'BUSINESS', 'ENTERPRISE'] }, status: 'ACTIVE' },
}).catch(() => 0),
prisma.user.findMany({
orderBy: { createdAt: 'desc' },
take: 8,
select: { id: true, name: true, email: true, createdAt: true, role: true },
}).catch(() => []),
])
// AI usage this month
let aiRequestsMonth = 0
try {
aiRequestsMonth = await prisma.usageLog.aggregate({
where: { periodStart: { gte: monthStart } },
_sum: { requestsCount: true },
}).then((r) => r._sum.requestsCount ?? 0)
} catch {
try {
aiRequestsMonth = await prisma.agentAction.count({
where: { createdAt: { gte: monthStart } },
})
} catch {
aiRequestsMonth = 0
}
}
return {
userCount,
noteCount,
notebookCount,
agentsEnabled,
agentRunsToday,
paidSubs,
aiRequestsMonth,
recentUsers,
}
}
export default async function AdminPage() {
const users = await getUsers()
const [users, stats] = await Promise.all([getUsers(), getAdminDashboardStats()])
// Mock metrics data - in a real app, these would come from analytics
const metrics = [
{
title: 'Total Users',
value: users.length,
trend: { value: 12, isPositive: true },
title: 'Utilisateurs',
value: stats.userCount,
trend: undefined as { value: number; isPositive: boolean } | undefined,
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
},
{
title: 'Active Sessions',
value: '24',
trend: { value: 8, isPositive: true },
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Total Notes',
value: '1,234',
trend: { value: 24, isPositive: true },
title: 'Notes',
value: stats.noteCount.toLocaleString('fr-FR'),
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
},
{
title: 'AI Requests',
value: '856',
trend: { value: 5, isPositive: false },
title: 'Abonnements payants',
value: stats.paidSubs,
icon: <CreditCard className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
},
{
title: 'Agents actifs',
value: stats.agentsEnabled,
icon: <Bot className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
},
{
title: 'Exécutions agents (24h)',
value: stats.agentRunsToday,
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Usage IA (mois)',
value: stats.aiRequestsMonth.toLocaleString('fr-FR'),
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
]
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Dashboard
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Overview of your application metrics
</p>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div>
<h1 className="text-3xl font-bold text-foreground">
Administration
</h1>
<p className="text-muted-foreground mt-1">
Vue d&apos;ensemble en temps réel {stats.notebookCount} carnets · {users.length} comptes listés
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/admin/billing"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Facturation & quotas
</Link>
<Link
href="/admin/ai"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Config IA
</Link>
<Link
href="/admin/users"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Utilisateurs
</Link>
<Link
href="/admin/settings"
className="text-xs font-medium px-3 py-2 rounded-lg border border-border bg-card hover:bg-muted transition-colors"
>
Paramètres
</Link>
</div>
</div>
<AdminMetrics metrics={metrics} />
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Recent Activity
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent activity will be displayed here.
</p>
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Derniers utilisateurs
</h2>
{stats.recentUsers.length === 0 ? (
<p className="text-muted-foreground text-sm">Aucun utilisateur.</p>
) : (
<ul className="divide-y divide-border">
{stats.recentUsers.map((u) => (
<li key={u.id} className="py-2.5 flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{u.name || u.email}</p>
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
</div>
<div className="text-right shrink-0">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{u.role}</span>
<p className="text-[10px] text-muted-foreground">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString('fr-FR') : '—'}
</p>
</div>
</li>
))}
</ul>
)}
</div>
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Raccourcis ops
</h2>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>· Configurer les Price IDs Stripe dans <Link href="/admin/billing" className="text-primary underline">Facturation</Link></li>
<li>· Vérifier les quotas IA par feature (BASIC / PRO / BUSINESS)</li>
<li>· Tester les providers dans <Link href="/admin/ai" className="text-primary underline">IA</Link></li>
<li>· CRON_SECRET requis pour agents planifiés (entrypoint Docker)</li>
<li>· Notes indexées via embeddings + fragments (y compris notes courtes)</li>
</ul>
</div>
</div>
</div>
)

View File

@@ -11,21 +11,21 @@ export default function SettingsLayout({
}) {
const { t } = useLanguage()
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-zinc-950">
<header className="px-4 sm:px-8 md:px-12 pt-8 sm:pt-14 md:pt-20 pb-6 sm:pb-10 md:pb-16 space-y-6 sm:space-y-10 md:space-y-12 shrink-0">
<div className="flex items-start gap-3">
<button
className="md:hidden p-2 -ms-1 text-ink/70 hover:bg-ink/5 rounded-lg transition-colors shrink-0 mt-1"
className="md:hidden p-2 -ms-1 text-ink dark:text-zinc-200 hover:bg-ink/5 dark:hover:bg-white/10 rounded-lg transition-colors shrink-0 mt-1"
onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label={t('settings.title')}
>
<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">
<h1 className="text-3xl sm:text-5xl md:text-[64px] font-serif text-ink dark:text-zinc-50 tracking-tight leading-none italic font-medium">
{t('settings.title')}
</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60 mt-4">
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete dark:text-zinc-500 opacity-60 mt-4">
{t('settings.description')}
</p>
</div>

View File

@@ -240,10 +240,10 @@ export async function runAgent(id: string) {
return { success: false, agentId: id, error: 'Agent introuvable' }
}
// Fire and forget — return immediately so the UI doesn't block
import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(id, userId))
.then(() => { /* revalidation is handled client-side via polling */ })
// Load module first so import failures surface immediately (not silently after response)
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
void executeAgent(id, userId)
.catch(err => console.error('[runAgent] Background error:', err))
return { success: true, agentId: id, status: 'running' }
@@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) {
}
try {
const existing = await prisma.agent.findFirst({
where: { id, userId: session.user.id },
select: {
id: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
})
if (!existing) throw new Error('Agent introuvable')
const data: { isEnabled: boolean; nextRun?: Date | null } = { isEnabled }
// When re-enabling a scheduled agent, ensure nextRun is set (was often null → never cron'd)
if (isEnabled && existing.frequency !== 'manual' && existing.frequency !== 'one-shot') {
const nextRun = calculateNextRun({
frequency: existing.frequency,
scheduledTime: existing.scheduledTime,
scheduledDay: existing.scheduledDay,
timezone: existing.timezone,
})
data.nextRun = nextRun
}
const agent = await prisma.agent.update({
where: { id, userId: session.user.id },
data: { isEnabled }
data,
})
return { success: true, agent }
} catch (error) {

View File

@@ -39,10 +39,18 @@ export async function POST(request: NextRequest) {
{ level, count, language: language || 'fr' }
)
// Prefer AI short title over raw user prompt (notebookName from client is often the full message)
const resolvedName = (
result.notebookName?.trim()
|| (notebookName && notebookName.trim().length <= 60 ? notebookName.trim() : '')
|| result.notes[0]?.title?.trim()
|| topic.trim().slice(0, 60)
).slice(0, 80)
// 1. Create notebook
const notebook = await prisma.notebook.create({
data: {
name: notebookName || topic,
name: resolvedName,
icon: notebookIcon || '📚',
userId: session.user.id,
order: 0,

View File

@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { stripe } from '@/lib/stripe';
import { resolvePriceId } from '@/lib/billing/stripe-prices';
import { isBillingEnabled, resolvePriceId } from '@/lib/billing/stripe-prices';
import { prisma } from '@/lib/prisma';
import { z } from 'zod';
const bodySchema = z.object({
tier: z.enum(['PRO', 'BUSINESS']),
interval: z.enum(['month', 'year']),
/** Prefer hosted redirect when embedded checkout is unavailable */
mode: z.enum(['hosted', 'embedded']).optional(),
});
export async function POST(req: NextRequest) {
@@ -16,17 +18,46 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!(await isBillingEnabled())) {
return NextResponse.json({ error: 'Billing is not enabled' }, { status: 403 });
}
const secret = process.env.STRIPE_SECRET_KEY;
if (!secret || secret === 'sk_test_placeholder') {
return NextResponse.json(
{ error: 'Stripe is not configured (STRIPE_SECRET_KEY missing)' },
{ status: 503 },
);
}
const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const { tier, interval } = parsed.data;
const preferredMode = parsed.data.mode ?? 'hosted';
const userId = session.user.id;
const userEmail = session.user.email;
try {
const priceId = await resolvePriceId(tier, interval);
let priceId: string;
try {
priceId = await resolvePriceId(tier, interval);
} catch (e) {
console.error('[billing/create-checkout] price resolve failed:', e);
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
if (priceId.startsWith('price_mock_')) {
return NextResponse.json(
{ error: 'Stripe price IDs not configured. Set them in Admin > Billing.' },
{ status: 503 },
);
}
const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined;
@@ -41,8 +72,7 @@ export async function POST(req: NextRequest) {
metadata: { userId },
});
customerId = customer.id;
// Update DB to save the real Stripe customer ID
await prisma.subscription.upsert({
where: { userId },
update: { stripeCustomerId: customerId },
@@ -52,8 +82,8 @@ export async function POST(req: NextRequest) {
tier: 'BASIC',
status: 'ACTIVE',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000), // temp basic dates
}
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 3600 * 1000),
},
});
}
@@ -61,29 +91,51 @@ export async function POST(req: NextRequest) {
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`;
const sessionParams = {
// Hosted Checkout is the most reliable path (redirect). Embedded is optional.
if (preferredMode === 'embedded') {
try {
const embedded = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded' as any,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
} as any);
if (embedded.client_secret) {
return NextResponse.json({
clientSecret: embedded.client_secret,
sessionId: embedded.id,
});
}
} catch (embeddedErr) {
console.warn('[billing/create-checkout] embedded failed, falling back to hosted:', embeddedErr);
}
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription' as const,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded_page',
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' },
allow_promotion_codes: true,
};
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
});
if (checkoutSession.client_secret) {
return NextResponse.json({
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
});
if (!checkoutSession.url) {
return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
}
return NextResponse.json({ url: checkoutSession.url });
return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
} catch (error) {
console.error('[billing/create-checkout]', error);
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
const msg = error instanceof Error ? error.message : 'Failed to create checkout session';
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View File

@@ -27,10 +27,12 @@ export async function POST(request: NextRequest) {
try {
const now = new Date()
// Due = nextRun in the past; also backfill agents whose nextRun was never set
// (legacy / toggle bug) — but only schedule them, don't stampede-execute all nulls
const dueAgents = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { not: 'manual' },
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: { lte: now },
},
select: {
@@ -40,13 +42,44 @@ export async function POST(request: NextRequest) {
scheduledTime: true,
scheduledDay: true,
timezone: true,
nextRun: true,
},
orderBy: { nextRun: 'asc' },
})
if (dueAgents.length === 0) {
return NextResponse.json({ success: true, executed: 0 })
// One-time repair: set nextRun for enabled scheduled agents stuck with null
const unscheduled = await prisma.agent.findMany({
where: {
isEnabled: true,
frequency: { notIn: ['manual', 'one-shot'] },
nextRun: null,
},
select: {
id: true,
frequency: true,
scheduledTime: true,
scheduledDay: true,
timezone: true,
},
take: 50,
})
for (const a of unscheduled) {
const nextRun = calculateNextRun({
frequency: a.frequency,
scheduledTime: a.scheduledTime,
scheduledDay: a.scheduledDay,
timezone: a.timezone,
})
await prisma.agent.update({ where: { id: a.id }, data: { nextRun } })
}
if (dueAgents.length === 0) {
return NextResponse.json({
success: true,
executed: 0,
repairedSchedules: unscheduled.length,
})
}
const results: { id: string; success: boolean; error?: string }[] = []
@@ -56,7 +89,6 @@ export async function POST(request: NextRequest) {
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, agent.userId)
// Calculate and set next run
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,
@@ -64,7 +96,6 @@ export async function POST(request: NextRequest) {
timezone: agent.timezone,
})
await prisma.agent.update({
where: { id: agent.id },
data: { nextRun },
@@ -75,7 +106,6 @@ export async function POST(request: NextRequest) {
const msg = error instanceof Error ? error.message : 'Unknown error'
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
// Still schedule next run even on failure
const nextRun = calculateNextRun({
frequency: agent.frequency,
scheduledTime: agent.scheduledTime,

View File

@@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
import { auth } from '@/auth'
import { writeUploadMeta } from '@/lib/upload-access'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
@@ -14,14 +15,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: 'No file uploaded' },
{ status: 400 }
)
return NextResponse.json({ error: 'No file uploaded' }, { status: 400 })
}
if (!ALLOWED_TYPES.includes(file.type)) {
@@ -33,7 +32,6 @@ export async function POST(request: NextRequest) {
}
const buffer = Buffer.from(await file.arrayBuffer())
// Resolve extension from file name, falling back to MIME type (e.g. clipboard pastes)
let ext = path.extname(file.name).toLowerCase()
if (!['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
const mimeToExt: Record<string, string> = {
@@ -44,23 +42,28 @@ export async function POST(request: NextRequest) {
}
ext = mimeToExt[file.type] || '.png'
}
const filename = `${randomUUID()}${ext}`
// Ensure directory exists (data/uploads is outside public/, served via API route)
const uploadDir = path.join(process.cwd(), 'data/uploads/notes')
// Ownership in path: /uploads/notes/{userId}/{filename}
const relativeUnderNotes = `${userId}/${filename}`
const uploadDir = path.join(process.cwd(), 'data', 'uploads', 'notes', userId)
await mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, filename)
await writeFile(filePath, buffer)
await writeUploadMeta(relativeUnderNotes, userId)
return NextResponse.json({
success: true,
url: `/uploads/notes/${filename}`
const url = `/uploads/notes/${relativeUnderNotes}`
return NextResponse.json({
success: true,
url,
})
} catch (error) {
console.error('[upload]', error)
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
{ status: 500 },
)
}
}

View File

@@ -21,16 +21,30 @@ export async function GET(
const session = await auth()
const { path: segments } = await params
// Only serve from uploads/notes/ subdirectory
if (segments[0] !== 'notes') {
// Only serve from uploads/notes/ (flat or userId/file)
if (!segments.length || segments[0] !== 'notes') {
return new NextResponse('Not found', { status: 404 })
}
// Reject path traversal
if (segments.some((s) => s === '..' || s.includes('\0'))) {
return new NextResponse('Forbidden', { status: 403 })
}
const filename = segments[segments.length - 1]
const allowed = await canAccessUploadedNoteImage(filename, session?.user?.id)
// Never serve meta sidecars as images
if (filename.endsWith('.meta.json')) {
return new NextResponse('Not found', { status: 404 })
}
const allowed = await canAccessUploadedNoteImage(segments, session?.user?.id)
if (!allowed) {
return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', {
status: session?.user?.id ? 403 : 401,
headers: {
// Do not let browsers / CDNs cache a denial (would stick a broken image)
'Cache-Control': 'no-store',
},
})
}
@@ -40,9 +54,8 @@ export async function GET(
return new NextResponse('Unsupported file type', { status: 400 })
}
// Prevent path traversal
const safePath = path.join(UPLOAD_DIR, ...segments)
if (!safePath.startsWith(UPLOAD_DIR)) {
if (!safePath.startsWith(UPLOAD_DIR + path.sep) && safePath !== UPLOAD_DIR) {
return new NextResponse('Forbidden', { status: 403 })
}
@@ -57,7 +70,8 @@ export async function GET(
return new NextResponse(buffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000, immutable',
// private: only for authenticated session fetches; still long cache after auth ok
'Cache-Control': 'private, max-age=31536000, immutable',
'Content-Length': String(buffer.length),
},
})

View File

@@ -1623,7 +1623,11 @@ html.font-system * {
height: auto;
display: block;
margin: 0.5em 0;
transition: filter 0.15s ease;
transition: filter 0.15s ease, outline 0.15s ease;
cursor: pointer;
/* Ensure clicks select the node (not swallowed by surrounding layout) */
position: relative;
z-index: 1;
}
.notion-editor-wrapper .ProseMirror img:hover {
@@ -1635,6 +1639,14 @@ html.font-system * {
outline-offset: 2px;
}
/* Image node wrapper when selected as block */
.notion-editor-wrapper .ProseMirror .ProseMirror-selectednode img,
.notion-editor-wrapper .ProseMirror img.ProseMirror-selectednode {
outline: 2px solid var(--primary);
outline-offset: 2px;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 25%, transparent);
}
/* --- Links --- */
.notion-editor-wrapper .ProseMirror a {
color: var(--primary);

View File

@@ -25,14 +25,14 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
return (
<div
className={cn(
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4',
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4',
className
)}
>
{metrics.map((metric, index) => (
<Card
key={index}
className="p-6 bg-white dark:bg-zinc-900 border-gray-200 dark:border-gray-800"
className="p-6 bg-card border-border"
>
<div className="flex items-start justify-between">
<div className="flex-1">

View File

@@ -1064,13 +1064,13 @@ export function ContextualAIChat({
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><Layout size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.sectionLabel')}</p>
@@ -1170,13 +1170,13 @@ export function ContextualAIChat({
</div>
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="group relative p-6 rounded-2xl bg-card dark:bg-white/5 border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-brand-accent" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
<div className="p-2 bg-slate-50 dark:bg-white/10 rounded-lg text-brand-accent"><BookOpen size={18} /></div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5>
<p className="text-[9px] text-foreground/40 uppercase tracking-tight">{t('ai.generate.diagramReadyHint')}</p>
@@ -1280,7 +1280,7 @@ export function ContextualAIChat({
{ id: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
].map((mode) => (
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5')}>
<button key={mode.id} onClick={() => setResourceMode(mode.id as any)} className={cn("flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center", resourceMode === mode.id ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-card dark:bg-white/5 border-border hover:bg-slate-50 dark:hover:bg-white/10')}>
<span className={cn("text-[10px] font-bold", resourceMode === mode.id ? 'text-brand-accent' : 'text-ink')}>{mode.label}</span>
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>

View File

@@ -112,6 +112,7 @@ export function NoteContentArea() {
ref={richTextEditorRef}
content={state.content}
onChange={(v: string) => actions.setContent(v)}
onChangeImmediate={(v: string) => actions.setContentImmediate(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
noteId={note.id}
@@ -129,6 +130,7 @@ export function NoteContentArea() {
ref={richTextEditorRef}
content={state.content}
onChange={actions.setContent}
onChangeImmediate={actions.setContentImmediate}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
noteId={note.id}

View File

@@ -0,0 +1,192 @@
'use client'
/**
* Couverture sous le titre (full-page).
* - Affichée uniquement si une image de couverture est **explicite** (pas auto depuis le corps).
* - Permet de retirer la couverture sans supprimer les images du contenu.
* - Permet de choisir parmi les images de la note (corps + galerie).
*/
import { useState } from 'react'
import { ImageIcon, X, Check, Images } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
interface NoteCoverHeroProps {
/** URL de couverture explicite (note.images[0]), null = pas de couverture */
coverUrl: string | null
/** Candidats (images du contenu + galerie) pour le sélecteur */
candidates: string[]
readOnly?: boolean
title?: string
onSetCover: (url: string) => void
onClearCover: () => void
}
export function NoteCoverHero({
coverUrl,
candidates,
readOnly,
title,
onSetCover,
onClearCover,
}: NoteCoverHeroProps) {
const { t } = useLanguage()
const [pickerOpen, setPickerOpen] = useState(false)
const uniqueCandidates = Array.from(new Set(candidates.filter(Boolean)))
const hasCover = Boolean(coverUrl)
const canPick = !readOnly && uniqueCandidates.length > 0
// Rien à montrer : pas de cover et pas de candidats (ou lecture seule sans cover)
if (!hasCover && (readOnly || uniqueCandidates.length === 0)) {
return null
}
// Pas de cover mais des images dans la note → barre discrète pour en choisir une
if (!hasCover && canPick) {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/30 px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
<ImageIcon className="h-4 w-4 shrink-0 opacity-70" />
<span className="truncate">
{t('notes.coverNoneHint') || 'Aucune image de couverture — choisissez une image de la note ou laissez vide.'}
</span>
</div>
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-border bg-background hover:bg-muted transition-colors shrink-0"
>
<Images className="h-3.5 w-3.5" />
{t('notes.coverChoose') || 'Choisir une couverture'}
</button>
{pickerOpen && (
<CoverPicker
candidates={uniqueCandidates}
selected={null}
onSelect={(url) => {
onSetCover(url)
setPickerOpen(false)
}}
onClose={() => setPickerOpen(false)}
t={t}
/>
)}
</div>
)
}
if (!hasCover || !coverUrl) return null
return (
<div className="group/cover relative aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={coverUrl}
alt={title || ''}
className="w-full h-full object-cover grayscale contrast-110 group-hover/cover:grayscale-0 transition-all duration-500"
/>
{!readOnly && (
<div className="absolute inset-x-0 top-0 p-3 flex items-start justify-end gap-2 opacity-0 group-hover/cover:opacity-100 focus-within:opacity-100 transition-opacity">
{uniqueCandidates.length > 1 && (
<button
type="button"
onClick={() => setPickerOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1.5 rounded-lg bg-black/60 text-white hover:bg-black/75 backdrop-blur-sm transition-colors"
title={t('notes.coverChoose') || 'Changer la couverture'}
>
<Images className="h-3.5 w-3.5" />
{t('notes.coverChange') || 'Changer'}
</button>
)}
<button
type="button"
onClick={onClearCover}
className="inline-flex items-center justify-center h-8 w-8 rounded-lg bg-black/60 text-white hover:bg-red-600/90 backdrop-blur-sm transition-colors"
title={t('notes.coverRemove') || 'Retirer la couverture'}
aria-label={t('notes.coverRemove') || 'Retirer la couverture'}
>
<X className="h-4 w-4" />
</button>
</div>
)}
{pickerOpen && !readOnly && (
<div className="absolute inset-x-0 bottom-0 p-3 bg-gradient-to-t from-black/70 to-transparent">
<CoverPicker
candidates={uniqueCandidates}
selected={coverUrl}
onSelect={(url) => {
onSetCover(url)
setPickerOpen(false)
}}
onClose={() => setPickerOpen(false)}
t={t}
dark
/>
</div>
)}
</div>
)
}
function CoverPicker({
candidates,
selected,
onSelect,
onClose,
t,
dark,
}: {
candidates: string[]
selected: string | null
onSelect: (url: string) => void
onClose: () => void
t: (key: string) => string
dark?: boolean
}) {
return (
<div className={cn('w-full', dark ? '' : 'col-span-full pt-1')}>
<div className="flex items-center justify-between gap-2 mb-2">
<p className={cn('text-[11px] font-medium', dark ? 'text-white/80' : 'text-muted-foreground')}>
{t('notes.coverPickLabel') || 'Image de couverture'}
</p>
<button
type="button"
onClick={onClose}
className={cn('text-[11px] underline-offset-2 hover:underline', dark ? 'text-white/70' : 'text-muted-foreground')}
>
{t('notes.close') || 'Fermer'}
</button>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{candidates.map((url) => {
const isSelected = selected === url
return (
<button
key={url}
type="button"
onClick={() => onSelect(url)}
className={cn(
'relative shrink-0 h-16 w-24 rounded-lg overflow-hidden border-2 transition-all',
isSelected
? 'border-brand-accent ring-2 ring-brand-accent/40'
: dark
? 'border-white/20 hover:border-white/50'
: 'border-border hover:border-foreground/30',
)}
>
<img src={url} alt="" className="h-full w-full object-cover" />
{isSelected && (
<span className="absolute inset-0 flex items-center justify-center bg-black/30">
<Check className="h-5 w-5 text-white drop-shadow" />
</span>
)}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -332,10 +332,29 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setLinks(links.filter((_, i) => i !== index))
}
/** Images extraites du corps (TipTap / markdown) — pas de couverture auto */
const contentImages = useMemo(() => {
if (isMarkdown) {
const urls = new Set<string>()
for (const match of content.matchAll(/!\[.*?\]\((.*?)\)/g)) {
const src = match[1]?.trim()
if (src) urls.add(src)
}
return Array.from(urls)
}
return extractImagesFromHTML(content)
}, [content, isMarkdown])
/**
* allImages = galerie explicite + corps (pour IA, sélecteur de couverture).
* La couverture hero n'utilise PAS allImages[0] automatiquement.
*/
const allImages = useMemo(() => {
const extracted = !isMarkdown ? extractImagesFromHTML(content) : []
return Array.from(new Set([...images, ...extracted]))
}, [images, content, isMarkdown])
return Array.from(new Set([...images, ...contentImages]))
}, [images, contentImages])
/** Couverture explicite = première entrée de note.images (jamais auto depuis le corps) */
const coverImage = images[0] ?? null
const resolveContentForSave = useCallback((): string => {
if (!isMarkdown) {
@@ -345,27 +364,32 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return contentRef.current
}, [isMarkdown])
const resolveImagesForSave = useCallback((contentToSave: string): string[] => {
// Images présentes dans le contenu de l'éditeur (inline dans le HTML ou Markdown)
let contentImages: string[] = []
if (contentToSave) {
if (!isMarkdown) {
contentImages = extractImagesFromHTML(contentToSave)
} else {
const urls = new Set<string>()
const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g)
for (const match of matches) {
const src = match[1]?.trim()
if (src) urls.add(src)
}
contentImages = Array.from(urls)
}
}
// Images "standalone" uploadées séparément (hors contenu éditeur), non supprimées
const standaloneImages = images.filter(url => !removedImageUrls.includes(url) && !contentImages.includes(url))
// Fusion dédupliquée : images du contenu + images standalone conservées
return Array.from(new Set([...contentImages, ...standaloneImages]))
}, [isMarkdown, images, removedImageUrls])
/**
* note.images = couverture / galerie **explicite** uniquement.
* Ne plus fusionner les images du corps : sinon, coller une image dans l'éditeur
* la plaçait sous le titre, et la supprimer du corps la laissait en "standalone" forever.
*/
const resolveImagesForSave = useCallback((_contentToSave: string): string[] => {
return images.filter((url) => !removedImageUrls.includes(url))
}, [images, removedImageUrls])
const setCoverImage = useCallback((url: string) => {
setImages((prev) => {
const rest = prev.filter((u) => u !== url)
return [url, ...rest]
})
setRemovedImageUrls((prev) => prev.filter((u) => u !== url))
setIsDirty(true)
}, [])
const clearCoverImage = useCallback(() => {
setImages((prev) => {
if (prev.length === 0) return prev
setRemovedImageUrls((r) => Array.from(new Set([...r, ...prev])))
return []
})
setIsDirty(true)
}, [])
const handleGenerateTitles = async () => {
@@ -902,6 +926,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isAnalyzingSuggestions,
isMarkdown,
allImages,
contentImages,
coverImage,
colorClasses,
quotaExceededFeature,
autoSaveEnabled,
@@ -911,13 +937,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled
isAnalyzingSuggestions, isMarkdown, allImages, contentImages, coverImage, colorClasses, quotaExceededFeature, autoSaveEnabled
])
const actions: NoteEditorActions = useMemo(() => ({
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) },
setContentImmediate: (c) => { setContentImmediate(c); setIsDirty(true) },
setCheckItems,
handleCheckItem,
handleUpdateCheckItem,
@@ -930,6 +957,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setImages,
handleImageUpload,
handleRemoveImage,
setCoverImage,
clearCoverImage,
uploadImageFile,
setLinks,
handleAddLink,
@@ -974,6 +1003,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setQuotaExceededFeature,
toggleAutoSave,
}), [
setContent, setContentImmediate, setCoverImage, clearCoverImage,
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,

View File

@@ -4,7 +4,7 @@ import { useNoteEditorContext } from './note-editor-context'
import { NoteEditorToolbar } from './note-editor-toolbar'
import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area'
import { EditorImages } from '@/components/editor-images'
import { NoteCoverHero } from './note-cover-hero'
import { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog'
@@ -119,16 +119,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
)}
</div>
{/* Hero image — show first note image if present */}
{state.allImages.length > 0 && (
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl">
<img
src={state.allImages[0]}
alt={state.title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500"
/>
</div>
)}
{/* Couverture explicite — pas d'auto-promo des images collées dans le corps */}
<NoteCoverHero
coverUrl={state.coverImage}
candidates={state.allImages}
readOnly={readOnly}
title={state.title}
onSetCover={actions.setCoverImage}
onClearCover={actions.clearCoverImage}
/>
{/* Content area — max-w-4xl for wider reading column */}
<div className="max-w-4xl mx-auto w-full space-y-8 pb-32">

View File

@@ -539,8 +539,9 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<span className="text-sm font-medium">{t('notes.backToCollection')}</span>
</button>
<div className="flex items-center gap-4">
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
<div className="flex items-center gap-1.5 sm:gap-2">
{/* 1. Status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
: state.isDirty
@@ -557,165 +558,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</span>
)}
{state.isMarkdown && !readOnly && (
{/* 2. Save (primary) */}
{!readOnly && (
<button
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
<Eye size={16} />
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
)}
{state.isMarkdown && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
>
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
</button>
)}
<button
title={t('ai.openAssistant')}
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
</button>
<button
title={t('notes.brainstormThisIdea')}
aria-label={t('notes.brainstormThisIdeaAria')}
onClick={() => {
const title = note.title || ''
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
const seed = title ? `${title}. ${summary}` : summary
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
>
<Wind size={16} />
</button>
{!readOnly && (
<div className="relative">
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setShowEduMenu(v => !v)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
{showEduMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
<button
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
>
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
<GraduationCap size={16} />
</div>
<div>
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
</div>
</button>
<button
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
disabled={generatingExercises}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
>
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
</div>
<div>
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
</div>
</button>
</div>
</>
)}
</div>
)}
{!readOnly && voiceSupported && (
<button
title={voiceState === 'listening'
? (t('editor.voiceStop') || 'Arrêter la dictée')
: (t('editor.voiceStart') || 'Dicter du texte')}
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
onClick={toggleVoice}
className={cn(
'p-1.5 rounded-full border transition-all',
voiceState === 'listening'
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
)}
>
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
</button>
)}
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
aria-label={t('notes.attachments') || 'Attachments'}
onClick={onToggleAttachments}
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Paperclip size={16} />
{(attachmentsCount ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
{attachmentsCount}
</span>
)}
</button>
)}
{!readOnly && (
<div className="flex items-center gap-1.5">
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.isDirty
? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground/40 cursor-not-allowed'
)}
>
{state.isSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
</button>
</div>
)}
{/* 3. Publish */}
{!readOnly && (
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
<DropdownMenuTrigger asChild>
@@ -904,6 +765,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</DropdownMenu>
)}
{/* 4. Share */}
{!readOnly && (
<button
title={t('notes.shareNoteTitle')}
@@ -915,6 +777,151 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</button>
)}
{/* 5. AI assistant */}
<button
title={t('ai.openAssistant')}
aria-label={t('ai.openAssistant')}
onClick={() => { actions.setAiOpen(!state.aiOpen); actions.setInfoOpen(false) }}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.aiOpen
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Sparkles size={16} />
</button>
{/* 6. Brainstorm */}
<button
title={t('notes.brainstormThisIdea')}
aria-label={t('notes.brainstormThisIdeaAria')}
onClick={() => {
const title = note.title || ''
const summary = state.content?.replace(/<[^>]*>/g, '').slice(0, 200) || ''
const seed = title ? `${title}. ${summary}` : summary
if (!seed.trim()) return
window.open(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`, '_self')
}}
className="p-1.5 rounded-full border border-brand-accent/30 dark:border-brand-accent/50 text-brand-accent hover:bg-brand-accent/10 dark:hover:bg-brand-accent/20 transition-all"
>
<Wind size={16} />
</button>
{/* 7. Education (flashcards / exercises) */}
{!readOnly && (
<div className="relative">
<button
title={t('flashcards.toolbarGenerate')}
aria-label={t('flashcards.toolbarGenerate')}
onClick={() => setShowEduMenu(v => !v)}
className="p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<GraduationCap size={16} />
</button>
{showEduMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowEduMenu(false)} />
<div className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-border bg-card shadow-xl overflow-hidden">
<button
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
>
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
<GraduationCap size={16} />
</div>
<div>
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
</div>
</button>
<button
onClick={() => { setShowEduMenu(false); handleGenerateExercises() }}
disabled={generatingExercises}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left border-t border-border/30"
>
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
{generatingExercises ? <Loader2Icon size={16} className="animate-spin" /> : <PenTool size={16} />}
</div>
<div>
<div className="text-sm font-medium">{t('richTextEditor.generateExercises') || 'Générer des exercices'}</div>
<div className="text-[10px] text-muted-foreground">{t('richTextEditor.generateExercisesHint') || '5 exercices + corrigés'}</div>
</div>
</button>
</div>
</>
)}
</div>
)}
{/* 8. Voice / attachments / markdown tools */}
{!readOnly && voiceSupported && (
<button
title={voiceState === 'listening'
? (t('editor.voiceStop') || 'Arrêter la dictée')
: (t('editor.voiceStart') || 'Dicter du texte')}
aria-label={voiceState === 'listening' ? (t('richTextEditor.stopVoice') || 'Stop voice') : (t('richTextEditor.startVoice') || 'Start voice')}
onClick={toggleVoice}
className={cn(
'p-1.5 rounded-full border transition-all',
voiceState === 'listening'
? 'border-red-400 bg-red-50 dark:bg-red-950/30 text-red-500 animate-pulse'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
)}
>
{voiceState === 'listening' ? <MicOff size={16} /> : <Mic size={16} />}
</button>
)}
{!readOnly && onToggleAttachments && (
<button
title={t('notes.attachments') || 'Attachments'}
aria-label={t('notes.attachments') || 'Attachments'}
onClick={onToggleAttachments}
className="relative p-1.5 rounded-full border border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5 transition-all"
>
<Paperclip size={16} />
{(attachmentsCount ?? 0) > 0 && (
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-primary text-primary-foreground text-[8px] font-bold rounded-full flex items-center justify-center">
{attachmentsCount}
</span>
)}
</button>
)}
{state.isMarkdown && !readOnly && (
<button
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview
? 'bg-foreground text-background border-foreground'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5'
)}
>
<Eye size={16} />
</button>
)}
{state.isMarkdown && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
onClick={handleConvertToRichtext}
disabled={isConverting}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5',
isConverting && 'opacity-50 cursor-not-allowed'
)}
>
{isConverting ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />}
</button>
)}
{/* 9. Overflow menu */}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -52,6 +52,10 @@ export interface NoteEditorState {
isMarkdown: boolean
allImages: string[]
/** Images extraites du corps de la note (pas la couverture) */
contentImages: string[]
/** URL de couverture explicite (images[0]), null si aucune */
coverImage: string | null
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
quotaExceededFeature: string | null
}
@@ -61,6 +65,8 @@ export interface NoteEditorActions {
setDismissedTitleSuggestions: (dismissed: boolean) => void
setContent: (content: string) => void
/** Immediate content state (no 800ms debounce) — image paste, conversions, etc. */
setContentImmediate: (content: string) => void
setCheckItems: (items: CheckItem[]) => void
handleCheckItem: (id: string) => void
@@ -76,6 +82,10 @@ export interface NoteEditorActions {
setImages: (images: string[]) => void
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
handleRemoveImage: (index: number) => void
/** Définit l'image de couverture (sous le titre) sans retirer l'image du corps */
setCoverImage: (url: string) => void
/** Retire la couverture (les images du corps restent) */
clearCoverImage: () => void
uploadImageFile: (file: File) => Promise<string>
setLinks: (links: LinkMetadata[]) => void

View File

@@ -70,10 +70,11 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { NodeSelection } from '@tiptap/pm/state'
export interface RichTextEditorHandle {
getEditor: () => Editor | null
@@ -88,6 +89,8 @@ export interface RichTextEditorHandle {
export interface RichTextEditorProps {
content?: string
onChange?: (content: string) => void
/** Immediate parent state update (image paste, structural inserts) — skips typing debounce. */
onChangeImmediate?: (content: string) => void
className?: string
placeholder?: string
onImageUpload?: (file: File) => Promise<string>
@@ -137,6 +140,11 @@ const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch'
const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
const CustomImage = Image.extend({
// Treat image as a single selectable unit (Backspace/Delete must remove it)
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
@@ -149,22 +157,98 @@ const CustomImage = Image.extend({
}
}
}
}
},
addKeyboardShortcuts() {
const deleteSelectedImage = () => {
const { selection } = this.editor.state
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
return this.editor.chain().focus().deleteSelection().run()
}
// Cursor just after an image block → Backspace removes it
const { $from, empty } = selection
if (!empty) return false
const before = $from.nodeBefore
if (before?.type.name === 'image') {
const from = $from.pos - before.nodeSize
return this.editor.chain().focus().deleteRange({ from, to: $from.pos }).run()
}
// Cursor at start of paragraph right after image (common after paste under title)
if ($from.parentOffset === 0 && $from.depth >= 1) {
const index = $from.index($from.depth - 1)
if (index > 0) {
const prev = $from.node($from.depth - 1).child(index - 1)
if (prev.type.name === 'image') {
const pos = $from.before($from.depth) - prev.nodeSize
return this.editor.chain().focus().deleteRange({ from: pos, to: pos + prev.nodeSize }).run()
}
}
}
return false
}
return {
Backspace: deleteSelectedImage,
Delete: () => {
const { selection } = this.editor.state
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
return this.editor.chain().focus().deleteSelection().run()
}
// Cursor just before an image → Delete removes it
const { $from, empty } = selection
if (!empty) return false
const after = $from.nodeAfter
if (after?.type.name === 'image') {
return this.editor.chain().focus().deleteRange({ from: $from.pos, to: $from.pos + after.nodeSize }).run()
}
return false
},
}
},
})
/** Insert / toggle a bullet list without the toggle-off → re-insert glitch. */
function insertBulletList(editor: Editor) {
// Already a bullet list → toggle off (do not re-insert)
if (editor.isActive('bulletList')) {
editor.chain().focus().toggleBulletList().run()
return
}
// Convert from other list types if needed
if (editor.isActive('orderedList')) editor.chain().focus().toggleOrderedList().run()
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
const ok = editor.chain().focus().toggleBulletList().run()
if (!ok) {
editor.chain().focus().insertContent('<ul><li><p></p></li></ul>').run()
}
}
function insertOrderedList(editor: Editor) {
if (editor.isActive('orderedList')) {
editor.chain().focus().toggleOrderedList().run()
return
}
if (editor.isActive('bulletList')) editor.chain().focus().toggleBulletList().run()
if (editor.isActive('taskList')) editor.chain().focus().toggleTaskList().run()
const ok = editor.chain().focus().toggleOrderedList().run()
if (!ok) {
editor.chain().focus().insertContent('<ol><li><p></p></li></ol>').run()
}
}
const slashCommands: SlashItem[] = [
// Basic blocks
// Basic blocks — lists BEFORE table so defaults / shortcuts never collide with Tableau
{ title: 'Text', description: 'Plain paragraph', icon: Pilcrow, category: 'Basic blocks', shortcut: '¶', command: (e) => e.chain().focus().setParagraph().run() },
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', shortcut: '#', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', shortcut: '##', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', shortcut: '###', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => e.chain().focus().toggleBulletList().run() },
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
{ title: 'Bullet List', description: 'Unordered list', icon: List, category: 'Basic blocks', shortcut: '-', command: (e) => insertBulletList(e) },
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => insertOrderedList(e) },
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() },
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', shortcut: '>', command: (e) => e.chain().focus().toggleBlockquote().run() },
{ title: 'Code Block', description: 'Code snippet', icon: CodeXml, category: 'Basic blocks', shortcut: '```', command: (e) => e.chain().focus().toggleCodeBlock().run() },
{ title: 'Divider', description: 'Horizontal separator', icon: Minus, category: 'Basic blocks', shortcut: '---', command: (e) => e.chain().focus().setHorizontalRule().run() },
{ title: 'Table', description: 'Insert a simple table', icon: () => <span className="text-xs font-bold border rounded px-1">TBL</span>, category: 'Basic blocks', command: (e) => e.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
// Media
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
// Formatting
@@ -300,7 +384,7 @@ function useImageInsert() {
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
function RichTextEditor({ content, onChange, onChangeImmediate, className, placeholder, onImageUpload, noteId, notebookId, noteTitle, sourceUrl }, ref) {
const { t } = useLanguage()
const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert()
@@ -361,11 +445,22 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const editorInstanceRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const onChangeImmediateRef = useRef(onChangeImmediate)
onChangeImmediateRef.current = onChangeImmediate
const htmlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const emitContentChange = useCallback((html: string) => {
const emitContentChange = useCallback((html: string, opts?: { immediate?: boolean }) => {
lastEmittedContent.current = html
onChangeRef.current?.(html)
// Cancel pending debounced HTML emit so it cannot overwrite with stale doc
if (htmlDebounceRef.current) {
clearTimeout(htmlDebounceRef.current)
htmlDebounceRef.current = null
}
if (opts?.immediate && onChangeImmediateRef.current) {
onChangeImmediateRef.current(html)
} else {
onChangeRef.current?.(html)
}
}, [])
// Debounced version for onUpdate — avoids calling getHTML() on every keystroke
@@ -581,6 +676,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const hasImage = items.some(item => item.type.startsWith('image/'))
if (!hasImage) return false
event.preventDefault()
event.stopPropagation()
const imageFiles = items
.filter(item => item.type.startsWith('image/'))
.map(item => item.getAsFile())
@@ -592,20 +688,82 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
toast.info(t('notes.uploading'))
const url = await onImageUpload(file)
const ed = editorInstanceRef.current
if (!ed) continue
if (!ed || ed.isDestroyed) continue
// Insert at cursor; force immediate parent sync so save/ref don't drop the img
const inserted = ed.chain().focus().setImage({ src: url }).run()
if (inserted) {
emitContentChange(ed.getHTML())
const html = ed.getHTML()
emitContentChange(html, { immediate: true })
// Prefetch so the img is warm in the browser cache
const preload = new window.Image()
preload.src = url
toast.success(t('notes.uploadSuccess') || 'Image ajoutée')
} else {
toast.error(t('notes.uploadFailed') || 'Insertion image échouée')
}
} catch {
} catch (err) {
console.error('[editor] image paste failed:', err)
toast.error(t('notes.uploadFailed'))
}
}
})()
return true
}
},
// ProseMirror signature: (view, pos, event)
handleClick: (view, pos, event) => {
// Click on img → NodeSelection so Backspace/Delete and bubble toolbar work
const target = event.target as HTMLElement | null
if (!target || target.tagName !== 'IMG') return false
if (!view.editable) return false
try {
let imagePos: number | null = null
const nodeAt = view.state.doc.nodeAt(pos)
if (nodeAt?.type.name === 'image') {
imagePos = pos
} else {
const $pos = view.state.doc.resolve(pos)
if ($pos.nodeAfter?.type.name === 'image') {
imagePos = $pos.pos
} else if ($pos.nodeBefore?.type.name === 'image') {
imagePos = $pos.pos - $pos.nodeBefore.nodeSize
} else {
// Walk up: pos may sit inside / around the leaf
for (let d = $pos.depth; d > 0; d--) {
if ($pos.node(d).type.name === 'image') {
imagePos = $pos.before(d)
break
}
}
}
}
if (imagePos == null) {
// Last resort: posAtDOM on the img element
const domPos = view.posAtDOM(target, 0)
const $dom = view.state.doc.resolve(domPos)
if ($dom.nodeAfter?.type.name === 'image') imagePos = $dom.pos
else if (view.state.doc.nodeAt(domPos)?.type.name === 'image') imagePos = domPos
}
if (imagePos == null) return false
const node = view.state.doc.nodeAt(imagePos)
if (!node || node.type.name !== 'image') return false
const tr = view.state.tr.setSelection(NodeSelection.create(view.state.doc, imagePos))
view.dispatch(tr)
view.focus()
return true
} catch {
return false
}
},
},
onUpdate: ({ editor: e }) => {
onUpdate: ({ editor: e, transaction }) => {
// Image removed → immediate parent sync so a stale content prop cannot resurrect it
if (transaction.docChanged && (lastEmittedContent.current || '').includes('<img')) {
const html = e.getHTML()
if (!html.includes('<img')) {
emitContentChange(html, { immediate: true })
return
}
}
// Debounced getHTML() — avoids traversing the whole doc on every keystroke
emitContentChangeDebounced(e)
if (!e.isEditable) return
@@ -763,6 +921,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
useEffect(() => {
if (editor && content !== undefined && content !== lastEmittedContent.current) {
// While the user is editing, never clobber local deletes (e.g. removed image)
// with a stale parent `content` that has not caught up yet.
if (editor.isFocused) {
return
}
const html = content || ''
lastEmittedContent.current = html
queueMicrotask(() => {
@@ -774,7 +937,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
}
})
}
if (content !== undefined) {
if (content !== undefined && !editor?.isFocused) {
setCurrentNoteContent(content || '')
}
}, [content, editor, sourceUrl])
@@ -1103,7 +1266,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (!editor) return
editor.chain().focus().setImage({ src: url }).run()
setSmartPasteExtended(null)
emitContentChange(editor.getHTML())
emitContentChange(editor.getHTML(), { immediate: true })
}, [editor, emitContentChange])
const handlePasteUrlVideo = useCallback((url: string) => {
@@ -1521,9 +1684,47 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
{editorState.isImage && (
<>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">25%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button>
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">100%</button>
<button
type="button"
onClick={() => editor.chain().focus().updateAttributes('image', { width: '25%' }).run()}
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
title="25%"
>
25%
</button>
<button
type="button"
onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()}
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
title="50%"
>
50%
</button>
<button
type="button"
onClick={() => editor.chain().focus().updateAttributes('image', { width: '100%' }).run()}
className="notion-bubble-btn rounded-md text-xs font-medium px-1"
title="100%"
>
100%
</button>
<button
type="button"
onClick={() => {
const { selection } = editor.state
if (selection instanceof NodeSelection && selection.node.type.name === 'image') {
editor.chain().focus().deleteSelection().run()
} else if (editor.isActive('image')) {
// Fallback: delete the nearest selected image node
editor.chain().focus().deleteSelection().run()
}
}}
className="notion-bubble-btn rounded-md text-red-600 dark:text-red-400"
title={t('richTextEditor.deleteImage') || t('common.delete') || 'Supprimer l\'image'}
aria-label={t('richTextEditor.deleteImage') || 'Supprimer l\'image'}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
{aiOpen && (
@@ -1728,45 +1929,52 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const menuInteracting = useRef(false)
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([])
// Resolve by English command title — NEVER by array index (reorder-safe)
const sc = (enTitle: string) => {
const cmd = slashCommands.find(c => c.title === enTitle)
if (!cmd) throw new Error(`Slash command missing: ${enTitle}`)
return cmd
}
const localCommands: SlashMenuItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...slashCommands[28], title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
{ ...slashCommands[29], title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
{ ...slashCommands[30], title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'tableau', 'structured', 'structuree', 'structurée'] },
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...slashCommands[32], title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...slashCommands[33], title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'table', 'matieres', 'matières', 'plan'] },
{ ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
{ ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
{ ...slashCommands[37], title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
{ ...sc('Text'), title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
{ ...sc('Heading 1'), title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
{ ...sc('Heading 2'), title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
{ ...sc('Heading 3'), title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
{ ...sc('Bullet List'), title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text', slashKeywords: ['bullet', 'liste', 'puce', 'ul', 'puces'] },
{ ...sc('Numbered List'), title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text', slashKeywords: ['numbered', 'numérotée', 'numerotee', 'ol', '1.'] },
{ ...sc('To-do List'), title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text', slashKeywords: ['todo', 'tache', 'tâche', 'checkbox'] },
{ ...sc('Quote'), title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
{ ...sc('Code Block'), title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
{ ...sc('Divider'), title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
{ ...sc('Table'), title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data', slashKeywords: ['table', 'tableau', 'grid', 'grille'] },
{ ...sc('Image'), title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...sc('Align Left'), title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
{ ...sc('Align Center'), title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
{ ...sc('Align Right'), title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
{ ...sc('Clarifier'), title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...sc('Raccourcir'), title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...sc('Améliorer'), title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...sc('Développer'), title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...sc('Bold'), title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
{ ...sc('Italic'), title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
{ ...sc('Underline'), title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
{ ...sc('Strike'), title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
{ ...sc('Highlight'), title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
{ ...sc('Superscript'), title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
{ ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
{ ...sc('Link Preview'), title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] },
{ ...sc('Math'), title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
{ ...sc('Columns'), title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
{ ...sc("Écrire avec l'IA"), title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1870,7 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
toastAi(err)
}
finally { setAiLoading(false) }
} else if (item.title === 'Suggest Charts') {
} else if (
item.title === 'Suggest Charts'
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
) {
deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu()

View File

@@ -68,7 +68,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
level: t(LEVELS[level]),
count,
language,
notebookName: topic.trim(),
// Server generates a short title from the topic; do not force raw prompt as name
}),
})

View File

@@ -19,6 +19,7 @@
* - Pas de race condition (verrou par noteId)
*/
import { createHash } from 'crypto'
import PQueue from 'p-queue'
import { prisma } from '@/lib/prisma'
import { embeddingService } from './embedding.service'
@@ -78,7 +79,16 @@ export class ChunkIndexingService {
): Promise<IndexResult> {
const start = Date.now()
const plain = prepareNoteTextForEmbedding(title, content)
const newChunks = chunkNoteContent(noteId, plain)
// Index even very short notes (title-only or one-liners)
let newChunks = chunkNoteContent(noteId, plain)
if (newChunks.length === 0 && plain.trim().length > 0) {
// Force a single fragment when paragraph split yields nothing
const fragmentId = createHash('sha256')
.update(`${noteId}::${plain}`)
.digest('hex')
.slice(0, 32)
newChunks = [{ fragmentId, content: plain, chunkIndex: 0, charCount: plain.length }]
}
const newFragmentIds = new Set(newChunks.map((c) => c.fragmentId))
const existing = await prisma.noteEmbeddingChunk.findMany({

View File

@@ -9,12 +9,29 @@ export interface GeneratedNote {
}
export interface GeneratedCarnet {
/** Short notebook title (not the raw user prompt). */
notebookName?: string
notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }>
}
export type WizardProfile = 'student' | 'teacher' | 'engineer'
/** Word-count targets by level — Expert was failing (timeouts / truncated JSON). */
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
const l = (level || '').toLowerCase()
if (/expert|avancé|advanced|fort/.test(l)) {
return { min: 350, max: 700, label: 'expert (350700 mots/note — dense mais JSON-fiable)' }
}
if (/intermédiaire|intermediate|moyen/.test(l)) {
return { min: 250, max: 500, label: 'intermédiaire (250500 mots/note)' }
}
if (/débutant|beginner|facile|easy/.test(l)) {
return { min: 150, max: 350, label: 'débutant (150350 mots/note)' }
}
return { min: 200, max: 450, label: 'standard (200450 mots/note)' }
}
export class NotebookWizardService {
async generateCarnet(
profile: WizardProfile,
@@ -22,14 +39,52 @@ export class NotebookWizardService {
options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> {
const lang = options?.language || 'fr'
const count = options?.count || 6
// Cap count in expert mode to avoid oversized responses
const level = options?.level
const isExpert = /expert/i.test(level || '')
const count = Math.min(options?.count || 6, isExpert ? 5 : 10)
const prompts = this.buildPrompt(profile, topic, lang, count, options?.level)
const prompts = this.buildPrompt(profile, topic, lang, count, level)
const config = await getSystemConfig()
const provider = getChatProvider(config)
const raw = await provider.generateText(prompts)
let raw: string
try {
raw = await provider.generateText(prompts)
} catch (err) {
// Retry once with lighter prompt if expert/full generation fails
if (isExpert) {
const light = this.buildPrompt(profile, topic, lang, Math.min(count, 4), 'Avancé')
raw = await provider.generateText(light)
} else {
throw err
}
}
return this.parseResponse(raw, profile, lang)
const result = this.parseResponse(raw, profile, lang)
if (!result.notebookName?.trim()) {
result.notebookName = this.deriveNotebookName(topic, result.notes, lang)
}
return result
}
/** Fallback short name when the model omits notebookName. */
private deriveNotebookName(topic: string, notes: GeneratedNote[], lang: string): string {
const fromNote = notes[0]?.title?.trim()
if (fromNote && fromNote.length <= 80 && fromNote.length >= 3) {
// Prefer first chapter title cleaned of numbering
return fromNote.replace(/^\d+[\.\):\-]\s*/, '').slice(0, 80)
}
const cleaned = topic
.replace(/\s+/g, ' ')
.trim()
.replace(/^(je veux|i want|please|peux-tu|can you|crée|create|génère|generate)\b[\s,:-]*/i, '')
if (cleaned.length > 0 && cleaned.length <= 60) return cleaned
if (cleaned.length > 60) {
const cut = cleaned.slice(0, 57)
const lastSpace = cut.lastIndexOf(' ')
return (lastSpace > 20 ? cut.slice(0, lastSpace) : cut) + '…'
}
return lang === 'fr' ? 'Nouveau carnet' : 'New notebook'
}
private buildPrompt(
@@ -41,6 +96,7 @@ export class NotebookWizardService {
): string {
const langName = lang === 'fr' ? 'français' : lang === 'en' ? 'English' : lang === 'fa' ? 'فارسی' : lang === 'ar' ? 'العربية' : lang === 'de' ? 'Deutsch' : lang === 'es' ? 'Español' : lang === 'it' ? 'Italiano' : lang === 'pt' ? 'Português' : lang === 'ru' ? 'Русский' : lang === 'zh' ? '中文' : lang === 'ja' ? '日本語' : lang === 'ko' ? '한국어' : lang === 'nl' ? 'Nederlands' : lang === 'pl' ? 'Polski' : lang === 'hi' ? 'हिन्दी' : lang
const words = wordTargetsForLevel(level)
const schemaLabels = this.getSchemaLabels(lang)
const profileContext = {
student: `Tu crées des notes de cours pour un étudiant. Le contenu doit être pédagogique, clair, avec des exemples.`,
@@ -50,31 +106,36 @@ export class NotebookWizardService {
return `Tu es un expert pédagogue et créateur de contenu de haut niveau. ${profileContext}
Sujet : "${topic}"
Sujet (intention de l'utilisateur, NE PAS recopier tel quel comme titre de carnet) : "${topic}"
${level ? `Niveau : ${level}` : ''}
Langue : ${langName}
Nombre de notes à créer : ${count}
Profondeur cible : ${words.label}
CRÉE ${count} NOTES COMPLÈTES ET DÉTAILLÉES sur ce sujet. Chaque note doit faire ENTRE 800 ET 1500 MOTS minimum. C'est non négociable.
CRÉE ${count} NOTES COMPLÈTES sur ce sujet. Chaque note : environ ${words.min}${words.max} mots (qualité > longueur extrême).
IMPORTANT — NOM DU CARNET :
- Fournis "notebookName" : un titre COURT et PERTINENT (38 mots, max 60 caractères).
- Exemple : pour un long message sur la thermo HVAC → "Modélisation thermodynamique HVAC"
- N'utilise JAMAIS la phrase complète de l'utilisateur comme nom.
Le contenu doit être :
- COMPLET et APPROFONDI — pas de résumé superficiel
- Structuré avec des titres <h2> et <h3> pour les sections
- Des paragraphes développés avec des explications détaillées
- Des exemples concrets et des cas d'usage
- Des définitions précises dans des encadrés callout
${profile === 'student' ? '- Des "Points clés à retenir" en callout tip\n- Des "Pièges à éviter" en callout danger\n- Des exemples d\'application' : ''}
${profile === 'teacher' ? '- Des objectifs pédagogiques en début de note\n- Des sections "Exercices" avec 5 questions détaillées\n- Des corrigés indicatifs' : ''}
${profile === 'engineer' ? '- Des spécifications techniques précises\n- Des tableaux comparatifs\n- Des références aux normes et standards' : ''}
- Clair et structuré avec <h2> et <h3>
- Des exemples concrets
- Des définitions dans des callouts
${profile === 'student' ? '- Points clés en callout tip\n- Pièges à éviter en callout danger' : ''}
${profile === 'teacher' ? '- Objectifs pédagogiques\n- Section Exercices (35 questions)' : ''}
${profile === 'engineer' ? '- Spécifications techniques\n- Tableaux comparatifs si utile' : ''}
FORMAT DE SORTIE — JSON UNIQUEMENT :
FORMAT DE SORTIE — JSON UNIQUEMENT (pas de markdown hors du bloc) :
\`\`\`json
{
"notebookName": "Titre court du carnet",
"notes": [
{
"title": "Titre détaillé et descriptif",
"title": "Titre de chapitre descriptif",
"difficulty": "facile",
"content": "<h2>Introduction</h2><p>Plusieurs paragraphes détaillés...</p>..."
"content": "<h2>Introduction</h2><p>...</p>..."
}
],
"schemaProperties": [
@@ -84,31 +145,17 @@ FORMAT DE SORTIE — JSON UNIQUEMENT :
}
\`\`\`
BLOCS HTML DISPONIBLES — UTILISE-LES ABONDAMMENT :
BLOCS HTML DISPONIBLES :
1. **Callout** (encadré coloré) :
<div data-type="callout-block" data-callout-type="info"><p>Définition importante...</p></div>
Types : info, warning, tip, success, danger
1. Callout : <div data-type="callout-block" data-callout-type="info"><p>...</p></div> (info|warning|tip|success|danger)
2. Toggle : <div data-type="toggle-block" data-opened="true"><p>Titre</p><p>Contenu</p></div>
3. Math : <div data-type="math-equation" data-latex="E = mc^2"></div> — JAMAIS $$
4. Colonnes : <div data-type="columns" cols="2">...</div>
5. Sommaire : <div data-type="outline-block"></div>
6. HTML : <h2>/<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <pre><code>
2. **Toggle** (section repliable pour détails) :
<div data-type="toggle-block" data-opened="true"><p>Cliquer pour voir les détails</p><p>Contenu détaillé...</p></div>
3. **Math** (formules LaTeX) — UTILISE LES BALISES DIRECTEMENT, pas de $$ :
Block : <div data-type="math-equation" data-latex="E = mc^2"></div>
Inline : <span data-type="inline-math" data-latex="x^2">x^2</span>
N'UTILISE JAMAIS $$ ou $ comme délimiteur — utilise TOUJOURS les balises HTML ci-dessus.
4. **Colonnes** (comparaison côte à côte) :
<div data-type="columns" cols="2"><div data-type="column" index="0"><p>Concept A...</p></div><div data-type="column" index="1"><p>Concept B...</p></div></div>
5. **Sommaire** (début de note) :
<div data-type="outline-block"></div>
6. HTML standard : <h1>/<h2>/<h3>, <p>, <ul>/<ol>/<li>, <table>, <blockquote>, <pre><code>
IMPORTANT : Chaque note DOIT faire entre 800 et 1500 mots. Sois exhaustif. Développe chaque concept avec des exemples, des explications, et du contexte. N'écris pas de résumés courts.
Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
Les "difficulty" doivent varier : facile/moyen/difficile.
Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dans le HTML).`
}
private getSchemaLabels(lang: string) {
@@ -193,7 +240,11 @@ Les "difficulty" doivent varier : mélange facile/moyen/difficile.`
{ name: defaultLabels.difficulty, type: 'select', options: [defaultLabels.easy, defaultLabels.medium, defaultLabels.hard] },
]
return { notes, schemaProperties }
const notebookName = typeof parsed.notebookName === 'string'
? parsed.notebookName.trim().slice(0, 80)
: undefined
return { notebookName, notes, schemaProperties }
}
}

View File

@@ -20,14 +20,26 @@ export async function deleteImageFileSafely(imageUrl: string, excludeNoteId?: st
try {
const notes = await prisma.note.findMany({
where: { images: { contains: imageUrl } },
where: {
OR: [
{ images: { contains: imageUrl } },
{ content: { contains: imageUrl } },
],
},
select: { id: true },
})
const otherRefs = notes.filter(n => n.id !== excludeNoteId)
if (otherRefs.length > 0) return // File still referenced elsewhere
const filePath = path.join(process.cwd(), 'data', imageUrl)
// imageUrl = /uploads/notes/... → data/uploads/notes/...
const filePath = path.join(process.cwd(), 'data', imageUrl.replace(/^\//, ''))
await fs.unlink(filePath)
// Remove ownership sidecar if present
try {
await fs.unlink(filePath + '.meta.json')
} catch {
/* no meta */
}
} catch {
// File already gone or unreadable -- silently skip
}

View File

@@ -14,7 +14,8 @@ import { createHash } from 'crypto'
const CHUNK_TARGET_CHARS = 1000
const CHUNK_OVERLAP_CHARS = 200
const MIN_FRAGMENT_CHARS = 10
/** Allow short notes (flashcards, reminders, one-liners) to be indexed. */
const MIN_FRAGMENT_CHARS = 1
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
export interface NoteChunk {

View File

@@ -1,37 +1,118 @@
import { readFile } from 'fs/promises'
import path from 'path'
import { prisma } from './prisma'
/** Whether a note image upload may be served to the current viewer. */
const NOTES_UPLOAD_ROOT = path.join(process.cwd(), 'data', 'uploads', 'notes')
export type UploadMeta = {
userId: string
createdAt: number
}
/**
* Canonical URL form:
* /uploads/notes/{userId}/{filename} ← preferred (ownership in path)
* Legacy:
* /uploads/notes/{filename} ← meta sidecar or note content
*/
export function uploadMetaPathForRelative(relativeUnderNotes: string): string {
// relativeUnderNotes e.g. "userId/uuid.png" or "uuid.png"
return path.join(NOTES_UPLOAD_ROOT, `${relativeUnderNotes}.meta.json`)
}
export async function writeUploadMeta(relativeUnderNotes: string, userId: string): Promise<void> {
const { writeFile, mkdir } = await import('fs/promises')
const full = uploadMetaPathForRelative(relativeUnderNotes)
await mkdir(path.dirname(full), { recursive: true })
const meta: UploadMeta = { userId, createdAt: Date.now() }
await writeFile(full, JSON.stringify(meta), 'utf8')
}
async function readUploadMeta(relativeUnderNotes: string): Promise<UploadMeta | null> {
try {
const raw = await readFile(uploadMetaPathForRelative(relativeUnderNotes), 'utf8')
const parsed = JSON.parse(raw) as UploadMeta
if (parsed?.userId && typeof parsed.userId === 'string') return parsed
return null
} catch {
return null
}
}
/**
* @param pathSegments path after `/uploads/` e.g. `['notes', userId, 'file.png']` or `['notes', 'file.png']`
*/
export async function canAccessUploadedNoteImage(
filename: string,
pathSegments: string[],
userId: string | null | undefined,
): Promise<boolean> {
if (pathSegments.length < 2 || pathSegments[0] !== 'notes') return false
// notes/{userId}/{filename}
if (pathSegments.length >= 3) {
const ownerId = pathSegments[1]
const filename = pathSegments[pathSegments.length - 1]
const relative = pathSegments.slice(1).join('/') // userId/file.png
const imagePath = `/uploads/notes/${relative}`
// 1) Path ownership — definitive for new uploads
if (userId && ownerId === userId) return true
// 2) Meta (redundant but safe)
if (userId) {
const meta = await readUploadMeta(relative)
if (meta?.userId === userId) return true
}
// 3) Public note embeds this URL
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
if (published) return true
if (!userId) return false
// 4) Private note owned by user embeds this URL
return noteContainsImage({ imagePath, filename, userId })
}
// Legacy: notes/{filename}
const filename = pathSegments[1]
const imagePath = `/uploads/notes/${filename}`
const published = await prisma.note.findFirst({
where: {
isPublic: true,
trashedAt: null,
OR: [
{ content: { contains: imagePath } },
{ images: { contains: filename } },
],
},
select: { id: true },
})
if (userId) {
const meta = await readUploadMeta(filename)
if (meta?.userId === userId) return true
}
const published = await noteContainsImage({ imagePath, filename, isPublic: true })
if (published) return true
if (!userId) return false
return noteContainsImage({ imagePath, filename, userId })
}
const owned = await prisma.note.findFirst({
where: {
userId,
trashedAt: null,
OR: [
{ content: { contains: imagePath } },
{ images: { contains: filename } },
],
},
async function noteContainsImage(opts: {
imagePath: string
filename: string
userId?: string
isPublic?: boolean
}): Promise<boolean> {
// Match full URL only (not bare filename) to avoid accidental cross-access
const where: Record<string, unknown> = {
trashedAt: null,
OR: [
{ content: { contains: opts.imagePath } },
{ images: { contains: opts.imagePath } },
// images JSON sometimes stored without leading path prefix
{ images: { contains: opts.filename } },
],
}
if (opts.isPublic) where.isPublic = true
if (opts.userId) where.userId = opts.userId
const row = await prisma.note.findFirst({
where,
select: { id: true },
})
return !!owned
return !!row
}

View File

@@ -132,6 +132,11 @@
"reminderPastError": "Reminder must be in the future",
"reminderRemoved": "Reminder removed",
"addImage": "Add image",
"coverNoneHint": "No cover image — pick one from the note or leave empty.",
"coverChoose": "Choose cover",
"coverChange": "Change",
"coverRemove": "Remove cover",
"coverPickLabel": "Cover image",
"addLink": "Add link",
"linkAdded": "Link added",
"linkMetadataFailed": "Could not fetch link metadata",

View File

@@ -132,6 +132,11 @@
"reminderPastError": "Le rappel doit être dans le futur",
"reminderRemoved": "Rappel supprimé",
"addImage": "Ajouter une image",
"coverNoneHint": "Aucune image de couverture — choisissez une image de la note ou laissez vide.",
"coverChoose": "Choisir une couverture",
"coverChange": "Changer",
"coverRemove": "Retirer la couverture",
"coverPickLabel": "Image de couverture",
"addLink": "Ajouter un lien",
"linkAdded": "Lien ajouté",
"linkMetadataFailed": "Impossible de récupérer les métadonnées du lien",

View File

@@ -7,9 +7,12 @@ describe('US-CHUNK-1 : Chunking sémantique', () => {
expect(chunks.length).toBe(0)
})
test('note très courte (< 10 chars) → aucun fragment', () => {
test('note très courte (one-liner) → 1 fragment indexable', () => {
// MIN_FRAGMENT_CHARS = 1 : notes courtes doivent être indexées (recherche / Memory Echo)
const chunks = chunkNoteContent('note1', 'Hello')
expect(chunks.length).toBe(0)
expect(chunks.length).toBe(1)
expect(chunks[0].content).toBe('Hello')
expect(chunks[0].charCount).toBe(5)
})
test('note courte (< 1000 chars) → 1 seul fragment', () => {