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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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