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 { getUsers } from '@/app/actions/admin'
import { AdminMetrics } from '@/components/admin-metrics' 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() { 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 = [ const metrics = [
{ {
title: 'Total Users', title: 'Utilisateurs',
value: users.length, value: stats.userCount,
trend: { value: 12, isPositive: true }, trend: undefined as { value: number; isPositive: boolean } | undefined,
icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />, icon: <Users className="h-5 w-5 text-primary dark:text-primary-foreground" />,
}, },
{ {
title: 'Active Sessions', title: 'Notes',
value: '24', value: stats.noteCount.toLocaleString('fr-FR'),
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 },
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />, icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
}, },
{ {
title: 'AI Requests', title: 'Abonnements payants',
value: '856', value: stats.paidSubs,
trend: { value: 5, isPositive: false }, 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" />, icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
}, },
] ]
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <div>
Dashboard <h1 className="text-3xl font-bold text-foreground">
</h1> Administration
<p className="text-gray-600 dark:text-gray-400 mt-1"> </h1>
Overview of your application metrics <p className="text-muted-foreground mt-1">
</p> 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> </div>
<AdminMetrics metrics={metrics} /> <AdminMetrics metrics={metrics} />
<div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6"> <div className="grid md:grid-cols-2 gap-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4"> <div className="bg-card rounded-lg shadow-sm overflow-hidden border border-border p-6">
Recent Activity <h2 className="text-lg font-semibold text-foreground mb-4">
</h2> Derniers utilisateurs
<p className="text-gray-600 dark:text-gray-400"> </h2>
Recent activity will be displayed here. {stats.recentUsers.length === 0 ? (
</p> <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>
</div> </div>
) )

View File

@@ -11,21 +11,21 @@ export default function SettingsLayout({
}) { }) {
const { t } = useLanguage() const { t } = useLanguage()
return ( return (
<div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-dark-paper"> <div className="flex flex-col h-full bg-[#F2F0E9] dark:bg-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"> <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"> <div className="flex items-start gap-3">
<button <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'))} onClick={() => window.dispatchEvent(new CustomEvent('open-mobile-sidebar'))}
aria-label={t('settings.title')} aria-label={t('settings.title')}
> >
<Menu size={22} /> <Menu size={22} />
</button> </button>
<div> <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')} {t('settings.title')}
</h1> </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')} {t('settings.description')}
</p> </p>
</div> </div>

View File

@@ -240,10 +240,10 @@ export async function runAgent(id: string) {
return { success: false, agentId: id, error: 'Agent introuvable' } return { success: false, agentId: id, error: 'Agent introuvable' }
} }
// Fire and forget — return immediately so the UI doesn't block // Load module first so import failures surface immediately (not silently after response)
import('@/lib/ai/services/agent-executor.service') const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
.then(({ executeAgent }) => executeAgent(id, userId))
.then(() => { /* revalidation is handled client-side via polling */ }) void executeAgent(id, userId)
.catch(err => console.error('[runAgent] Background error:', err)) .catch(err => console.error('[runAgent] Background error:', err))
return { success: true, agentId: id, status: 'running' } return { success: true, agentId: id, status: 'running' }
@@ -319,9 +319,35 @@ export async function toggleAgent(id: string, isEnabled: boolean) {
} }
try { 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({ const agent = await prisma.agent.update({
where: { id, userId: session.user.id }, where: { id, userId: session.user.id },
data: { isEnabled } data,
}) })
return { success: true, agent } return { success: true, agent }
} catch (error) { } catch (error) {

View File

@@ -39,10 +39,18 @@ export async function POST(request: NextRequest) {
{ level, count, language: language || 'fr' } { 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 // 1. Create notebook
const notebook = await prisma.notebook.create({ const notebook = await prisma.notebook.create({
data: { data: {
name: notebookName || topic, name: resolvedName,
icon: notebookIcon || '📚', icon: notebookIcon || '📚',
userId: session.user.id, userId: session.user.id,
order: 0, order: 0,

View File

@@ -1,13 +1,15 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth'; import { auth } from '@/auth';
import { stripe } from '@/lib/stripe'; 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 { prisma } from '@/lib/prisma';
import { z } from 'zod'; import { z } from 'zod';
const bodySchema = z.object({ const bodySchema = z.object({
tier: z.enum(['PRO', 'BUSINESS']), tier: z.enum(['PRO', 'BUSINESS']),
interval: z.enum(['month', 'year']), 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) { export async function POST(req: NextRequest) {
@@ -16,17 +18,46 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 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()); const parsed = bodySchema.safeParse(await req.json());
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
} }
const { tier, interval } = parsed.data; const { tier, interval } = parsed.data;
const preferredMode = parsed.data.mode ?? 'hosted';
const userId = session.user.id; const userId = session.user.id;
const userEmail = session.user.email; const userEmail = session.user.email;
try { 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 } }); const subscription = await prisma.subscription.findUnique({ where: { userId } });
let customerId = subscription?.stripeCustomerId ?? undefined; let customerId = subscription?.stripeCustomerId ?? undefined;
@@ -42,7 +73,6 @@ export async function POST(req: NextRequest) {
}); });
customerId = customer.id; customerId = customer.id;
// Update DB to save the real Stripe customer ID
await prisma.subscription.upsert({ await prisma.subscription.upsert({
where: { userId }, where: { userId },
update: { stripeCustomerId: customerId }, update: { stripeCustomerId: customerId },
@@ -52,8 +82,8 @@ export async function POST(req: NextRequest) {
tier: 'BASIC', tier: 'BASIC',
status: 'ACTIVE', status: 'ACTIVE',
currentPeriodStart: new Date(), 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 proto = req.headers.get('x-forwarded-proto') ?? 'http';
const origin = `${proto}://${host}`; 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, customer: customerId,
mode: 'subscription' as const, mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
ui_mode: 'embedded_page', success_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`,
return_url: `${origin}/settings/billing?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${origin}/settings/billing?canceled=1`,
metadata: { userId, tier }, metadata: { userId, tier },
subscription_data: { metadata: { userId, tier } }, subscription_data: { metadata: { userId, tier } },
customer_update: { address: 'auto' }, customer_update: { address: 'auto' },
allow_promotion_codes: true, allow_promotion_codes: true,
}; });
const checkoutSession = await stripe.checkout.sessions.create(sessionParams as any);
if (checkoutSession.client_secret) { if (!checkoutSession.url) {
return NextResponse.json({ return NextResponse.json({ error: 'Checkout session has no URL' }, { status: 500 });
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
});
} }
return NextResponse.json({ url: checkoutSession.url }); return NextResponse.json({ url: checkoutSession.url, sessionId: checkoutSession.id });
} catch (error) { } catch (error) {
console.error('[billing/create-checkout]', 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 { try {
const now = new Date() 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({ const dueAgents = await prisma.agent.findMany({
where: { where: {
isEnabled: true, isEnabled: true,
frequency: { not: 'manual' }, frequency: { notIn: ['manual', 'one-shot'] },
nextRun: { lte: now }, nextRun: { lte: now },
}, },
select: { select: {
@@ -40,13 +42,44 @@ export async function POST(request: NextRequest) {
scheduledTime: true, scheduledTime: true,
scheduledDay: true, scheduledDay: true,
timezone: true, timezone: true,
nextRun: true,
}, },
orderBy: { nextRun: 'asc' },
}) })
if (dueAgents.length === 0) { // One-time repair: set nextRun for enabled scheduled agents stuck with null
return NextResponse.json({ success: true, executed: 0 }) 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 }[] = [] 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 { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
const result = await executeAgent(agent.id, agent.userId) const result = await executeAgent(agent.id, agent.userId)
// Calculate and set next run
const nextRun = calculateNextRun({ const nextRun = calculateNextRun({
frequency: agent.frequency, frequency: agent.frequency,
scheduledTime: agent.scheduledTime, scheduledTime: agent.scheduledTime,
@@ -64,7 +96,6 @@ export async function POST(request: NextRequest) {
timezone: agent.timezone, timezone: agent.timezone,
}) })
await prisma.agent.update({ await prisma.agent.update({
where: { id: agent.id }, where: { id: agent.id },
data: { nextRun }, data: { nextRun },
@@ -75,7 +106,6 @@ export async function POST(request: NextRequest) {
const msg = error instanceof Error ? error.message : 'Unknown error' const msg = error instanceof Error ? error.message : 'Unknown error'
console.error(`[CronAgents] Agent ${agent.id} failed:`, msg) console.error(`[CronAgents] Agent ${agent.id} failed:`, msg)
// Still schedule next run even on failure
const nextRun = calculateNextRun({ const nextRun = calculateNextRun({
frequency: agent.frequency, frequency: agent.frequency,
scheduledTime: agent.scheduledTime, scheduledTime: agent.scheduledTime,

View File

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

View File

@@ -21,16 +21,30 @@ export async function GET(
const session = await auth() const session = await auth()
const { path: segments } = await params const { path: segments } = await params
// Only serve from uploads/notes/ subdirectory // Only serve from uploads/notes/ (flat or userId/file)
if (segments[0] !== 'notes') { if (!segments.length || segments[0] !== 'notes') {
return new NextResponse('Not found', { status: 404 }) 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 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) { if (!allowed) {
return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', { return new NextResponse(session?.user?.id ? 'Forbidden' : 'Unauthorized', {
status: session?.user?.id ? 403 : 401, 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 }) return new NextResponse('Unsupported file type', { status: 400 })
} }
// Prevent path traversal
const safePath = path.join(UPLOAD_DIR, ...segments) 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 }) return new NextResponse('Forbidden', { status: 403 })
} }
@@ -57,7 +70,8 @@ export async function GET(
return new NextResponse(buffer, { return new NextResponse(buffer, {
headers: { headers: {
'Content-Type': contentType, '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), 'Content-Length': String(buffer.length),
}, },
}) })

View File

@@ -1623,7 +1623,11 @@ html.font-system * {
height: auto; height: auto;
display: block; display: block;
margin: 0.5em 0; 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 { .notion-editor-wrapper .ProseMirror img:hover {
@@ -1635,6 +1639,14 @@ html.font-system * {
outline-offset: 2px; 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 --- */ /* --- Links --- */
.notion-editor-wrapper .ProseMirror a { .notion-editor-wrapper .ProseMirror a {
color: var(--primary); color: var(--primary);

View File

@@ -25,14 +25,14 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
return ( return (
<div <div
className={cn( 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 className
)} )}
> >
{metrics.map((metric, index) => ( {metrics.map((metric, index) => (
<Card <Card
key={index} 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 items-start justify-between">
<div className="flex-1"> <div className="flex-1">

View File

@@ -1064,13 +1064,13 @@ export function ContextualAIChat({
<div className="h-px flex-1 bg-border/40" /> <div className="h-px flex-1 bg-border/40" />
</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"> <div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-brand-accent" /> <Layout size={80} className="text-brand-accent" />
</div> </div>
<div className="relative space-y-5"> <div className="relative space-y-5">
<div className="flex items-center gap-3"> <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"> <div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.slides')}</h5> <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> <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> </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"> <div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-brand-accent" /> <BookOpen size={80} className="text-brand-accent" />
</div> </div>
<div className="relative space-y-5"> <div className="relative space-y-5">
<div className="flex items-center gap-3"> <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"> <div className="space-y-0.5">
<h5 className="text-sm font-bold text-foreground leading-none">{t('ai.generate.diagram')}</h5> <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> <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: 'complete', label: t('ai.resource.modeComplete'), sub: t('ai.resource.modeCompleteDesc') },
{ id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') }, { id: 'merge', label: t('ai.resource.modeMerge'), sub: t('ai.resource.modeMergeDesc') },
].map((mode) => ( ].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={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> <span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button> </button>

View File

@@ -112,6 +112,7 @@ export function NoteContentArea() {
ref={richTextEditorRef} ref={richTextEditorRef}
content={state.content} content={state.content}
onChange={(v: string) => actions.setContent(v)} onChange={(v: string) => actions.setContent(v)}
onChangeImmediate={(v: string) => actions.setContentImmediate(v)}
className="min-h-[280px]" className="min-h-[280px]"
onImageUpload={uploadImageFile} onImageUpload={uploadImageFile}
noteId={note.id} noteId={note.id}
@@ -129,6 +130,7 @@ export function NoteContentArea() {
ref={richTextEditorRef} ref={richTextEditorRef}
content={state.content} content={state.content}
onChange={actions.setContent} onChange={actions.setContent}
onChangeImmediate={actions.setContentImmediate}
className="min-h-[200px]" className="min-h-[200px]"
onImageUpload={uploadImageFile} onImageUpload={uploadImageFile}
noteId={note.id} 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)) 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 allImages = useMemo(() => {
const extracted = !isMarkdown ? extractImagesFromHTML(content) : [] return Array.from(new Set([...images, ...contentImages]))
return Array.from(new Set([...images, ...extracted])) }, [images, contentImages])
}, [images, content, isMarkdown])
/** Couverture explicite = première entrée de note.images (jamais auto depuis le corps) */
const coverImage = images[0] ?? null
const resolveContentForSave = useCallback((): string => { const resolveContentForSave = useCallback((): string => {
if (!isMarkdown) { if (!isMarkdown) {
@@ -345,27 +364,32 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return contentRef.current return contentRef.current
}, [isMarkdown]) }, [isMarkdown])
const resolveImagesForSave = useCallback((contentToSave: string): string[] => { /**
// Images présentes dans le contenu de l'éditeur (inline dans le HTML ou Markdown) * note.images = couverture / galerie **explicite** uniquement.
let contentImages: string[] = [] * Ne plus fusionner les images du corps : sinon, coller une image dans l'éditeur
if (contentToSave) { * la plaçait sous le titre, et la supprimer du corps la laissait en "standalone" forever.
if (!isMarkdown) { */
contentImages = extractImagesFromHTML(contentToSave) const resolveImagesForSave = useCallback((_contentToSave: string): string[] => {
} else { return images.filter((url) => !removedImageUrls.includes(url))
const urls = new Set<string>() }, [images, removedImageUrls])
const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g)
for (const match of matches) { const setCoverImage = useCallback((url: string) => {
const src = match[1]?.trim() setImages((prev) => {
if (src) urls.add(src) const rest = prev.filter((u) => u !== url)
} return [url, ...rest]
contentImages = Array.from(urls) })
} setRemovedImageUrls((prev) => prev.filter((u) => u !== url))
} setIsDirty(true)
// 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 const clearCoverImage = useCallback(() => {
return Array.from(new Set([...contentImages, ...standaloneImages])) setImages((prev) => {
}, [isMarkdown, images, removedImageUrls]) if (prev.length === 0) return prev
setRemovedImageUrls((r) => Array.from(new Set([...r, ...prev])))
return []
})
setIsDirty(true)
}, [])
const handleGenerateTitles = async () => { const handleGenerateTitles = async () => {
@@ -902,6 +926,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isAnalyzingSuggestions, isAnalyzingSuggestions,
isMarkdown, isMarkdown,
allImages, allImages,
contentImages,
coverImage,
colorClasses, colorClasses,
quotaExceededFeature, quotaExceededFeature,
autoSaveEnabled, autoSaveEnabled,
@@ -911,13 +937,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating, isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder, reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions, showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled isAnalyzingSuggestions, isMarkdown, allImages, contentImages, coverImage, colorClasses, quotaExceededFeature, autoSaveEnabled
]) ])
const actions: NoteEditorActions = useMemo(() => ({ const actions: NoteEditorActions = useMemo(() => ({
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) }, setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions, setDismissedTitleSuggestions,
setContent: (c) => { setContent(c); setIsDirty(true) }, setContent: (c) => { setContent(c); setIsDirty(true) },
setContentImmediate: (c) => { setContentImmediate(c); setIsDirty(true) },
setCheckItems, setCheckItems,
handleCheckItem, handleCheckItem,
handleUpdateCheckItem, handleUpdateCheckItem,
@@ -930,6 +957,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setImages, setImages,
handleImageUpload, handleImageUpload,
handleRemoveImage, handleRemoveImage,
setCoverImage,
clearCoverImage,
uploadImageFile, uploadImageFile,
setLinks, setLinks,
handleAddLink, handleAddLink,
@@ -974,6 +1003,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setQuotaExceededFeature, setQuotaExceededFeature,
toggleAutoSave, toggleAutoSave,
}), [ }), [
setContent, setContentImmediate, setCoverImage, clearCoverImage,
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem, handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload, handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave, handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,

View File

@@ -4,7 +4,7 @@ import { useNoteEditorContext } from './note-editor-context'
import { NoteEditorToolbar } from './note-editor-toolbar' import { NoteEditorToolbar } from './note-editor-toolbar'
import { NoteTitleBlock } from './note-title-block' import { NoteTitleBlock } from './note-title-block'
import { NoteContentArea } from './note-content-area' 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 { ComparisonModal } from '@/components/comparison-modal'
import { FusionModal } from '@/components/fusion-modal' import { FusionModal } from '@/components/fusion-modal'
import { ReminderDialog } from '@/components/reminder-dialog' import { ReminderDialog } from '@/components/reminder-dialog'
@@ -119,16 +119,15 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
)} )}
</div> </div>
{/* Hero image — show first note image if present */} {/* Couverture explicite — pas d'auto-promo des images collées dans le corps */}
{state.allImages.length > 0 && ( <NoteCoverHero
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-zinc-900 rounded-xl overflow-hidden shadow-xl"> coverUrl={state.coverImage}
<img candidates={state.allImages}
src={state.allImages[0]} readOnly={readOnly}
alt={state.title} title={state.title}
className="w-full h-full object-cover grayscale contrast-110 hover:grayscale-0 transition-all duration-500" onSetCover={actions.setCoverImage}
/> onClearCover={actions.clearCoverImage}
</div> />
)}
{/* Content area — max-w-4xl for wider reading column */} {/* Content area — max-w-4xl for wider reading column */}
<div className="max-w-4xl mx-auto w-full space-y-8 pb-32"> <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> <span className="text-sm font-medium">{t('notes.backToCollection')}</span>
</button> </button>
<div className="flex items-center gap-4"> <div className="flex items-center gap-1.5 sm:gap-2">
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none"> {/* 1. Status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none me-1">
{state.isSaving {state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></> ? <><Loader2 className="h-3 w-3 animate-spin" /><span>{t('notes.saving')}</span></>
: state.isDirty : state.isDirty
@@ -557,165 +558,25 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</span> </span>
)} )}
{state.isMarkdown && !readOnly && ( {/* 2. Save (primary) */}
{!readOnly && (
<button <button
title={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')} title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.showMarkdownPreview ? t('notes.markdownEditingTitle') : t('notes.markdownPreviewTitle')} aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)} onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn( className={cn(
'p-1.5 rounded-full border transition-all duration-300', 'p-1.5 rounded-full border transition-all duration-300',
state.showMarkdownPreview state.isDirty
? 'bg-foreground text-background border-foreground' ? 'bg-foreground text-background border-foreground hover:opacity-80'
: 'border-black/20 dark:border-white/20 text-foreground hover:bg-black/5 dark:hover:bg-white/5' : '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> </button>
)} )}
{state.isMarkdown && !readOnly && ( {/* 3. Publish */}
<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>
)}
{!readOnly && ( {!readOnly && (
<DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}> <DropdownMenu open={publishOpen} onOpenChange={setPublishOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -904,6 +765,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</DropdownMenu> </DropdownMenu>
)} )}
{/* 4. Share */}
{!readOnly && ( {!readOnly && (
<button <button
title={t('notes.shareNoteTitle')} title={t('notes.shareNoteTitle')}
@@ -915,6 +777,151 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
</button> </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 && ( {!readOnly && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@@ -52,6 +52,10 @@ export interface NoteEditorState {
isMarkdown: boolean isMarkdown: boolean
allImages: string[] 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] colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
quotaExceededFeature: string | null quotaExceededFeature: string | null
} }
@@ -61,6 +65,8 @@ export interface NoteEditorActions {
setDismissedTitleSuggestions: (dismissed: boolean) => void setDismissedTitleSuggestions: (dismissed: boolean) => void
setContent: (content: string) => void setContent: (content: string) => void
/** Immediate content state (no 800ms debounce) — image paste, conversions, etc. */
setContentImmediate: (content: string) => void
setCheckItems: (items: CheckItem[]) => void setCheckItems: (items: CheckItem[]) => void
handleCheckItem: (id: string) => void handleCheckItem: (id: string) => void
@@ -76,6 +82,10 @@ export interface NoteEditorActions {
setImages: (images: string[]) => void setImages: (images: string[]) => void
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
handleRemoveImage: (index: number) => 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> uploadImageFile: (file: File) => Promise<string>
setLinks: (links: LinkMetadata[]) => void setLinks: (links: LinkMetadata[]) => void

View File

@@ -70,10 +70,11 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2 ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, Trash2
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { NodeSelection } from '@tiptap/pm/state'
export interface RichTextEditorHandle { export interface RichTextEditorHandle {
getEditor: () => Editor | null getEditor: () => Editor | null
@@ -88,6 +89,8 @@ export interface RichTextEditorHandle {
export interface RichTextEditorProps { export interface RichTextEditorProps {
content?: string content?: string
onChange?: (content: string) => void onChange?: (content: string) => void
/** Immediate parent state update (image paste, structural inserts) — skips typing debounce. */
onChangeImmediate?: (content: string) => void
className?: string className?: string
placeholder?: string placeholder?: string
onImageUpload?: (file: File) => Promise<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 AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
const CustomImage = Image.extend({ const CustomImage = Image.extend({
// Treat image as a single selectable unit (Backspace/Delete must remove it)
atom: true,
selectable: true,
draggable: true,
addAttributes() { addAttributes() {
return { return {
...this.parent?.(), ...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[] = [ 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: '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 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 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: '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) => insertBulletList(e) },
{ 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) => insertOrderedList(e) },
{ title: 'Numbered List', description: 'Ordered numbered list', icon: ListOrdered, category: 'Basic blocks', shortcut: '1.', command: (e) => e.chain().focus().toggleOrderedList().run() },
{ title: 'To-do List', description: 'Checkboxes for tasks', icon: CheckSquare, category: 'Basic blocks', shortcut: '[]', command: (e) => e.chain().focus().toggleTaskList().run() }, { 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: '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: '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: '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 // Media
{ title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } }, { title: 'Image', description: 'Embed image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => { } },
// Formatting // Formatting
@@ -300,7 +384,7 @@ function useImageInsert() {
} }
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>( 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 { t } = useLanguage()
const { requestAiConsent } = useAiConsent() const { requestAiConsent } = useAiConsent()
const imageInsert = useImageInsert() const imageInsert = useImageInsert()
@@ -361,11 +445,22 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const editorInstanceRef = useRef<Editor | null>(null) const editorInstanceRef = useRef<Editor | null>(null)
const onChangeRef = useRef(onChange) const onChangeRef = useRef(onChange)
onChangeRef.current = onChange onChangeRef.current = onChange
const onChangeImmediateRef = useRef(onChangeImmediate)
onChangeImmediateRef.current = onChangeImmediate
const htmlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null) const htmlDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const emitContentChange = useCallback((html: string) => { const emitContentChange = useCallback((html: string, opts?: { immediate?: boolean }) => {
lastEmittedContent.current = html 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 // 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/')) const hasImage = items.some(item => item.type.startsWith('image/'))
if (!hasImage) return false if (!hasImage) return false
event.preventDefault() event.preventDefault()
event.stopPropagation()
const imageFiles = items const imageFiles = items
.filter(item => item.type.startsWith('image/')) .filter(item => item.type.startsWith('image/'))
.map(item => item.getAsFile()) .map(item => item.getAsFile())
@@ -592,20 +688,82 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
toast.info(t('notes.uploading')) toast.info(t('notes.uploading'))
const url = await onImageUpload(file) const url = await onImageUpload(file)
const ed = editorInstanceRef.current 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() const inserted = ed.chain().focus().setImage({ src: url }).run()
if (inserted) { 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')) toast.error(t('notes.uploadFailed'))
} }
} }
})() })()
return true 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 // Debounced getHTML() — avoids traversing the whole doc on every keystroke
emitContentChangeDebounced(e) emitContentChangeDebounced(e)
if (!e.isEditable) return if (!e.isEditable) return
@@ -763,6 +921,11 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
useEffect(() => { useEffect(() => {
if (editor && content !== undefined && content !== lastEmittedContent.current) { 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 || '' const html = content || ''
lastEmittedContent.current = html lastEmittedContent.current = html
queueMicrotask(() => { queueMicrotask(() => {
@@ -774,7 +937,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
} }
}) })
} }
if (content !== undefined) { if (content !== undefined && !editor?.isFocused) {
setCurrentNoteContent(content || '') setCurrentNoteContent(content || '')
} }
}, [content, editor, sourceUrl]) }, [content, editor, sourceUrl])
@@ -1103,7 +1266,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (!editor) return if (!editor) return
editor.chain().focus().setImage({ src: url }).run() editor.chain().focus().setImage({ src: url }).run()
setSmartPasteExtended(null) setSmartPasteExtended(null)
emitContentChange(editor.getHTML()) emitContentChange(editor.getHTML(), { immediate: true })
}, [editor, emitContentChange]) }, [editor, emitContentChange])
const handlePasteUrlVideo = useCallback((url: string) => { const handlePasteUrlVideo = useCallback((url: string) => {
@@ -1521,9 +1684,47 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
{editorState.isImage && ( {editorState.isImage && (
<> <>
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" /> <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
<button onClick={() => editor.chain().focus().updateAttributes('image', { width: '50%' }).run()} className="notion-bubble-btn rounded-md text-xs font-medium px-1">50%</button> type="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> 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 && ( {aiOpen && (
@@ -1728,45 +1929,52 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
const menuInteracting = useRef(false) const menuInteracting = useRef(false)
const [frequentCommands, setFrequentCommands] = useState<SlashMenuItem[]>([]) 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[] = [ const localCommands: SlashMenuItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' }, { ...sc('Text'), title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'text' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' }, { ...sc('Heading 1'), title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'text' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' }, { ...sc('Heading 2'), title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'text' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' }, { ...sc('Heading 3'), title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'text' },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'data' }, { ...sc('Bullet List'), title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text', slashKeywords: ['bullet', 'liste', 'puce', 'ul', 'puces'] },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'text' }, { ...sc('Numbered List'), title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text', slashKeywords: ['numbered', 'numérotée', 'numerotee', 'ol', '1.'] },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'text' }, { ...sc('To-do List'), title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text', slashKeywords: ['todo', 'tache', 'tâche', 'checkbox'] },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'text' }, { ...sc('Quote'), title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'text' }, { ...sc('Code Block'), title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'text' }, { ...sc('Divider'), title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'text' },
{ ...slashCommands[10], 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'] },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' }, { ...sc('Image'), title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' }, { ...sc('Align Left'), title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'text' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' }, { ...sc('Align Center'), title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'text' },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' }, { ...sc('Align Right'), title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'text' },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' }, { ...sc('Clarifier'), title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' }, { ...sc('Raccourcir'), title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' }, { ...sc('Améliorer'), title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' }, { ...sc('Développer'), title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' }, { ...sc('Bold'), title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'text' },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' }, { ...sc('Italic'), title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'text' },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' }, { ...sc('Underline'), title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'text' },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' }, { ...sc('Strike'), title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'text' },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' }, { ...sc('Highlight'), title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'text' },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' }, { ...sc('Superscript'), title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'text' },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' }, { ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' }, { ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' }, { ...sc('Présentation'), 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' }, { ...sc('Suggest Charts'), 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' }, { ...sc('Living Block'), 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'] }, { ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
{ ...slashCommands[31], title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] }, { ...sc('Toggle'), 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'] }, { ...sc('Callout'), 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'] }, { ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', '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'] }, { ...sc('Link Preview'), 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'] }, { ...sc('Math'), 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'] }, { ...sc('Columns'), 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("É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'), title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'), description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1870,7 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
toastAi(err) toastAi(err)
} }
finally { setAiLoading(false) } 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() deleteSlashText(); closeMenu(); onSuggestCharts()
} else if (item.title === t('richTextEditor.slashDatabase')) { } else if (item.title === t('richTextEditor.slashDatabase')) {
deleteSlashText(); closeMenu() deleteSlashText(); closeMenu()

View File

@@ -68,7 +68,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
level: t(LEVELS[level]), level: t(LEVELS[level]),
count, count,
language, 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) * - Pas de race condition (verrou par noteId)
*/ */
import { createHash } from 'crypto'
import PQueue from 'p-queue' import PQueue from 'p-queue'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { embeddingService } from './embedding.service' import { embeddingService } from './embedding.service'
@@ -78,7 +79,16 @@ export class ChunkIndexingService {
): Promise<IndexResult> { ): Promise<IndexResult> {
const start = Date.now() const start = Date.now()
const plain = prepareNoteTextForEmbedding(title, content) 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 newFragmentIds = new Set(newChunks.map((c) => c.fragmentId))
const existing = await prisma.noteEmbeddingChunk.findMany({ const existing = await prisma.noteEmbeddingChunk.findMany({

View File

@@ -9,12 +9,29 @@ export interface GeneratedNote {
} }
export interface GeneratedCarnet { export interface GeneratedCarnet {
/** Short notebook title (not the raw user prompt). */
notebookName?: string
notes: GeneratedNote[] notes: GeneratedNote[]
schemaProperties: Array<{ name: string; type: string; options?: string[] }> schemaProperties: Array<{ name: string; type: string; options?: string[] }>
} }
export type WizardProfile = 'student' | 'teacher' | 'engineer' 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 { export class NotebookWizardService {
async generateCarnet( async generateCarnet(
profile: WizardProfile, profile: WizardProfile,
@@ -22,14 +39,52 @@ export class NotebookWizardService {
options?: { level?: string; count?: number; language?: string } options?: { level?: string; count?: number; language?: string }
): Promise<GeneratedCarnet> { ): Promise<GeneratedCarnet> {
const lang = options?.language || 'fr' 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 config = await getSystemConfig()
const provider = getChatProvider(config) 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( private buildPrompt(
@@ -41,6 +96,7 @@ export class NotebookWizardService {
): string { ): 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 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 schemaLabels = this.getSchemaLabels(lang)
const profileContext = { const profileContext = {
student: `Tu crées des notes de cours pour un étudiant. Le contenu doit être pédagogique, clair, avec des exemples.`, 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} 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}` : ''} ${level ? `Niveau : ${level}` : ''}
Langue : ${langName} Langue : ${langName}
Nombre de notes à créer : ${count} 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 : Le contenu doit être :
- COMPLET et APPROFONDI — pas de résumé superficiel - Clair et structuré avec <h2> et <h3>
- Structuré avec des titres <h2> et <h3> pour les sections - Des exemples concrets
- Des paragraphes développés avec des explications détaillées - Des définitions dans des callouts
- Des exemples concrets et des cas d'usage ${profile === 'student' ? '- Points clés en callout tip\n- Pièges à éviter en callout danger' : ''}
- Des définitions précises dans des encadrés callout ${profile === 'teacher' ? '- Objectifs pédagogiques\n- Section Exercices (35 questions)' : ''}
${profile === 'student' ? '- Des "Points clés à retenir" en callout tip\n- Des "Pièges à éviter" en callout danger\n- Des exemples d\'application' : ''} ${profile === 'engineer' ? '- Spécifications techniques\n- Tableaux comparatifs si utile' : ''}
${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' : ''}
FORMAT DE SORTIE — JSON UNIQUEMENT : FORMAT DE SORTIE — JSON UNIQUEMENT (pas de markdown hors du bloc) :
\`\`\`json \`\`\`json
{ {
"notebookName": "Titre court du carnet",
"notes": [ "notes": [
{ {
"title": "Titre détaillé et descriptif", "title": "Titre de chapitre descriptif",
"difficulty": "facile", "difficulty": "facile",
"content": "<h2>Introduction</h2><p>Plusieurs paragraphes détaillés...</p>..." "content": "<h2>Introduction</h2><p>...</p>..."
} }
], ],
"schemaProperties": [ "schemaProperties": [
@@ -84,31 +145,17 @@ FORMAT DE SORTIE — JSON UNIQUEMENT :
} }
\`\`\` \`\`\`
BLOCS HTML DISPONIBLES — UTILISE-LES ABONDAMMENT : BLOCS HTML DISPONIBLES :
1. **Callout** (encadré coloré) : 1. Callout : <div data-type="callout-block" data-callout-type="info"><p>...</p></div> (info|warning|tip|success|danger)
<div data-type="callout-block" data-callout-type="info"><p>Définition importante...</p></div> 2. Toggle : <div data-type="toggle-block" data-opened="true"><p>Titre</p><p>Contenu</p></div>
Types : info, warning, tip, success, danger 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) : Les "difficulty" doivent varier : facile/moyen/difficile.
<div data-type="toggle-block" data-opened="true"><p>Cliquer pour voir les détails</p><p>Contenu détaillé...</p></div> Réponds UNIQUEMENT avec le JSON valide (échappement correct des guillemets dans le HTML).`
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.`
} }
private getSchemaLabels(lang: string) { 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] }, { 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 { try {
const notes = await prisma.note.findMany({ const notes = await prisma.note.findMany({
where: { images: { contains: imageUrl } }, where: {
OR: [
{ images: { contains: imageUrl } },
{ content: { contains: imageUrl } },
],
},
select: { id: true }, select: { id: true },
}) })
const otherRefs = notes.filter(n => n.id !== excludeNoteId) const otherRefs = notes.filter(n => n.id !== excludeNoteId)
if (otherRefs.length > 0) return // File still referenced elsewhere 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) await fs.unlink(filePath)
// Remove ownership sidecar if present
try {
await fs.unlink(filePath + '.meta.json')
} catch {
/* no meta */
}
} catch { } catch {
// File already gone or unreadable -- silently skip // File already gone or unreadable -- silently skip
} }

View File

@@ -14,7 +14,8 @@ import { createHash } from 'crypto'
const CHUNK_TARGET_CHARS = 1000 const CHUNK_TARGET_CHARS = 1000
const CHUNK_OVERLAP_CHARS = 200 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 const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
export interface NoteChunk { export interface NoteChunk {

View File

@@ -1,37 +1,118 @@
import { readFile } from 'fs/promises'
import path from 'path'
import { prisma } from './prisma' 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( export async function canAccessUploadedNoteImage(
filename: string, pathSegments: string[],
userId: string | null | undefined, userId: string | null | undefined,
): Promise<boolean> { ): 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 imagePath = `/uploads/notes/${filename}`
const published = await prisma.note.findFirst({ if (userId) {
where: { const meta = await readUploadMeta(filename)
isPublic: true, if (meta?.userId === userId) return true
trashedAt: null, }
OR: [
{ content: { contains: imagePath } }, const published = await noteContainsImage({ imagePath, filename, isPublic: true })
{ images: { contains: filename } },
],
},
select: { id: true },
})
if (published) return true if (published) return true
if (!userId) return false if (!userId) return false
return noteContainsImage({ imagePath, filename, userId })
}
const owned = await prisma.note.findFirst({ async function noteContainsImage(opts: {
where: { imagePath: string
userId, filename: string
trashedAt: null, userId?: string
OR: [ isPublic?: boolean
{ content: { contains: imagePath } }, }): Promise<boolean> {
{ images: { contains: filename } }, // 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 }, select: { id: true },
}) })
return !!owned return !!row
} }

View File

@@ -132,6 +132,11 @@
"reminderPastError": "Reminder must be in the future", "reminderPastError": "Reminder must be in the future",
"reminderRemoved": "Reminder removed", "reminderRemoved": "Reminder removed",
"addImage": "Add image", "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", "addLink": "Add link",
"linkAdded": "Link added", "linkAdded": "Link added",
"linkMetadataFailed": "Could not fetch link metadata", "linkMetadataFailed": "Could not fetch link metadata",

View File

@@ -132,6 +132,11 @@
"reminderPastError": "Le rappel doit être dans le futur", "reminderPastError": "Le rappel doit être dans le futur",
"reminderRemoved": "Rappel supprimé", "reminderRemoved": "Rappel supprimé",
"addImage": "Ajouter une image", "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", "addLink": "Ajouter un lien",
"linkAdded": "Lien ajouté", "linkAdded": "Lien ajouté",
"linkMetadataFailed": "Impossible de récupérer les métadonnées du lien", "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) 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') 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', () => { test('note courte (< 1000 chars) → 1 seul fragment', () => {