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:
@@ -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'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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -41,8 +72,7 @@ export async function POST(req: NextRequest) {
|
|||||||
metadata: { userId },
|
metadata: { userId },
|
||||||
});
|
});
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`
|
const filename = `${randomUUID()}${ext}`
|
||||||
|
// Ownership in path: /uploads/notes/{userId}/{filename}
|
||||||
// Ensure directory exists (data/uploads is outside public/, served via API route)
|
const relativeUnderNotes = `${userId}/${filename}`
|
||||||
const uploadDir = path.join(process.cwd(), 'data/uploads/notes')
|
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)
|
||||||
|
|
||||||
return NextResponse.json({
|
const url = `/uploads/notes/${relativeUnderNotes}`
|
||||||
success: true,
|
|
||||||
url: `/uploads/notes/${filename}`
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal file
192
memento-note/components/note-editor/note-cover-hero.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 (350–700 mots/note — dense mais JSON-fiable)' }
|
||||||
|
}
|
||||||
|
if (/intermédiaire|intermediate|moyen/.test(l)) {
|
||||||
|
return { min: 250, max: 500, label: 'intermédiaire (250–500 mots/note)' }
|
||||||
|
}
|
||||||
|
if (/débutant|beginner|facile|easy/.test(l)) {
|
||||||
|
return { min: 150, max: 350, label: 'débutant (150–350 mots/note)' }
|
||||||
|
}
|
||||||
|
return { min: 200, max: 450, label: 'standard (200–450 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 (3–8 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 (3–5 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user