fix: batch priorités — thème, scroll, wizard, agents, slides, charts, packs
Anti-flash dark (color-scheme + applyDocumentTheme), scroll pages publiques, prompts wizard/slides moins génériques, cron agents rattrapage nextRun null, slides carnet via notebookId, graphiques à 2 crédits + hint, audit dashboard, packs Stripe marqués non configurés sans price ID.
This commit is contained in:
@@ -19,8 +19,8 @@ export default async function PublicLayout({ children }: { children: React.React
|
||||
<PublicProviders initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
<div
|
||||
data-public-scroll-root
|
||||
className="fixed inset-0 z-0 overflow-y-auto overflow-x-hidden overscroll-y-contain"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
className="fixed inset-0 z-[1] h-[100dvh] w-full overflow-y-auto overflow-x-hidden overscroll-y-contain"
|
||||
style={{ WebkitOverflowScrolling: 'touch', touchAction: 'pan-y' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { QuotaExceededError } from '@/lib/entitlements'
|
||||
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
|
||||
import { slideGenerateCreditCost, resolveCreditCost } from '@/lib/credits'
|
||||
import { slideGenerateCreditCost, notebookSlideCreditCost, resolveCreditCost } from '@/lib/credits'
|
||||
import type { FeatureName } from '@/lib/quota-utils'
|
||||
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
||||
import {
|
||||
@@ -42,8 +42,10 @@ export async function POST(req: NextRequest) {
|
||||
const userId = session.user.id
|
||||
|
||||
const body = await req.json()
|
||||
const { noteId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
|
||||
noteId: string
|
||||
const { noteId, notebookId, type, theme, style, language, template, purpose, audience, slideCount } = body as {
|
||||
noteId?: string
|
||||
/** Si fourni (slides), génère un deck multi-notes du carnet */
|
||||
notebookId?: string
|
||||
type: GenerateType
|
||||
theme?: string
|
||||
style?: string
|
||||
@@ -54,21 +56,47 @@ export async function POST(req: NextRequest) {
|
||||
slideCount?: number
|
||||
}
|
||||
|
||||
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
||||
if ((!noteId && !notebookId) || !type || !TYPE_DEFAULTS[type]) {
|
||||
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
||||
}
|
||||
if (notebookId && type !== 'slide-generator') {
|
||||
return NextResponse.json({ error: 'notebookId réservé aux présentations' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Crédits globaux (respecte le BYOK) — slides = 1 + N
|
||||
let noteCountForPack = 1
|
||||
if (notebookId) {
|
||||
noteCountForPack = await prisma.note.count({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
},
|
||||
})
|
||||
if (noteCountForPack < 1) {
|
||||
return NextResponse.json({ error: 'Carnet vide ou introuvable' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Crédits globaux (respecte le BYOK) — slides note = 1+N ; carnet = coût notebook
|
||||
const featureKey = (type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate') as FeatureName
|
||||
const creditCost =
|
||||
type === 'slide-generator'
|
||||
? slideGenerateCreditCost(slideCount)
|
||||
? notebookId
|
||||
? notebookSlideCreditCost({ slideCount, noteCount: noteCountForPack })
|
||||
: slideGenerateCreditCost(slideCount)
|
||||
: resolveCreditCost(featureKey)
|
||||
try {
|
||||
await reserveAiUsageOrThrow(userId, featureKey, {
|
||||
amount: creditCost,
|
||||
slideCount: type === 'slide-generator' ? slideCount : undefined,
|
||||
metadata: { noteId, type, slideCount: slideCount ?? null },
|
||||
metadata: {
|
||||
noteId: noteId ?? null,
|
||||
notebookId: notebookId ?? null,
|
||||
type,
|
||||
slideCount: slideCount ?? null,
|
||||
noteCount: noteCountForPack,
|
||||
},
|
||||
lane: 'chat',
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -86,12 +114,28 @@ export async function POST(req: NextRequest) {
|
||||
throw e
|
||||
}
|
||||
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId },
|
||||
select: { id: true, title: true, notebookId: true },
|
||||
})
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
|
||||
let note: { id: string; title: string | null; notebookId: string | null } | null = null
|
||||
if (noteId) {
|
||||
note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId },
|
||||
select: { id: true, title: true, notebookId: true },
|
||||
})
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
|
||||
}
|
||||
} else if (notebookId) {
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: { id: notebookId, userId },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (!notebook) {
|
||||
return NextResponse.json({ error: 'Carnet introuvable' }, { status: 404 })
|
||||
}
|
||||
note = {
|
||||
id: notebookId,
|
||||
title: notebook.name,
|
||||
notebookId,
|
||||
}
|
||||
}
|
||||
|
||||
const defaults = TYPE_DEFAULTS[type]
|
||||
@@ -110,6 +154,10 @@ export async function POST(req: NextRequest) {
|
||||
role = `Génère un deck exécutif de haute qualité (titres d'action, une idée par slide, pas de filler, graphiques uniquement avec vrais chiffres).${recipeHint}`
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: 'Source introuvable' }, { status: 404 })
|
||||
}
|
||||
|
||||
const agentName = type === 'slide-generator'
|
||||
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
||||
@@ -136,6 +184,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const sourceNotebookId = notebookId || note.notebookId || undefined
|
||||
const agent = await prisma.agent.create({
|
||||
data: {
|
||||
name: agentName,
|
||||
@@ -149,8 +198,9 @@ export async function POST(req: NextRequest) {
|
||||
maxSteps: defaults.maxSteps,
|
||||
frequency: 'one-shot',
|
||||
isEnabled: true,
|
||||
sourceNoteIds: JSON.stringify([noteId]),
|
||||
targetNotebookId: note.notebookId ?? undefined,
|
||||
sourceNoteIds: noteId ? JSON.stringify([noteId]) : JSON.stringify([]),
|
||||
sourceNotebookId: notebookId ? notebookId : undefined,
|
||||
targetNotebookId: sourceNotebookId,
|
||||
slideTheme: theme ?? 'keynote',
|
||||
slideStyle: style ?? 'soft',
|
||||
userId,
|
||||
@@ -168,11 +218,22 @@ export async function POST(req: NextRequest) {
|
||||
userId,
|
||||
action: 'AI_REQUEST',
|
||||
resource: featureKey,
|
||||
metadata: { agentId: agent.id, noteId, featureKey },
|
||||
metadata: {
|
||||
agentId: agent.id,
|
||||
noteId: noteId ?? null,
|
||||
notebookId: notebookId ?? null,
|
||||
featureKey,
|
||||
creditCost,
|
||||
},
|
||||
ip: getClientIp(req),
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
agentId: agent.id,
|
||||
status: 'running',
|
||||
creditCost,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── GET : poll current agent status ──────────────────────────────────────
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
|
||||
orderBy: { nextRun: 'asc' },
|
||||
})
|
||||
|
||||
// One-time repair: set nextRun for enabled scheduled agents stuck with null
|
||||
// Repair: set nextRun for enabled scheduled agents stuck with null
|
||||
const unscheduled = await prisma.agent.findMany({
|
||||
where: {
|
||||
isEnabled: true,
|
||||
@@ -56,6 +56,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
frequency: true,
|
||||
scheduledTime: true,
|
||||
scheduledDay: true,
|
||||
@@ -63,6 +64,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
take: 50,
|
||||
})
|
||||
const repairedDue: typeof dueAgents = []
|
||||
for (const a of unscheduled) {
|
||||
const nextRun = calculateNextRun({
|
||||
frequency: a.frequency,
|
||||
@@ -70,10 +72,32 @@ export async function POST(request: NextRequest) {
|
||||
scheduledDay: a.scheduledDay,
|
||||
timezone: a.timezone,
|
||||
})
|
||||
await prisma.agent.update({ where: { id: a.id }, data: { nextRun } })
|
||||
// Si le créneau calculé est déjà passé / immédiat → exécuter ce cycle
|
||||
const effective = !nextRun || nextRun <= now ? now : nextRun
|
||||
await prisma.agent.update({ where: { id: a.id }, data: { nextRun: effective } })
|
||||
if (effective <= now) {
|
||||
repairedDue.push({
|
||||
id: a.id,
|
||||
userId: a.userId,
|
||||
frequency: a.frequency,
|
||||
scheduledTime: a.scheduledTime,
|
||||
scheduledDay: a.scheduledDay,
|
||||
timezone: a.timezone,
|
||||
nextRun: effective,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (dueAgents.length === 0) {
|
||||
const queue = [...dueAgents]
|
||||
const seen = new Set(dueAgents.map((a) => a.id))
|
||||
for (const a of repairedDue) {
|
||||
if (!seen.has(a.id)) {
|
||||
queue.push(a)
|
||||
seen.add(a.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
executed: 0,
|
||||
@@ -83,8 +107,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const results: { id: string; success: boolean; error?: string }[] = []
|
||||
|
||||
// Execute agents sequentially (max 3 per cycle)
|
||||
for (const agent of dueAgents.slice(0, 3)) {
|
||||
// Execute agents sequentially (max 5 per cycle — rattrapage + due)
|
||||
for (const agent of queue.slice(0, 5)) {
|
||||
try {
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
const result = await executeAgent(agent.id, agent.userId)
|
||||
|
||||
@@ -93,8 +93,15 @@ export default async function RootLayout({
|
||||
const htmlTheme = serverHtmlThemeState(resolvedTheme)
|
||||
const serverAccent = userSettings.accentColor ?? '#A47148'
|
||||
const serverTheme = resolvedTheme
|
||||
const wantsDarkClass =
|
||||
resolvedTheme === 'dark' ||
|
||||
resolvedTheme === 'midnight' ||
|
||||
// auto : le serveur ne connaît pas prefers-color-scheme → laisser le script client
|
||||
false
|
||||
const htmlStyle = {
|
||||
'--color-brand-accent': serverAccent,
|
||||
// Aide le navigateur à peindre scrollbars/inputs dans le bon mode
|
||||
colorScheme: wantsDarkClass || resolvedTheme === 'dark' || resolvedTheme === 'midnight' ? 'dark' : resolvedTheme === 'light' ? 'light' : undefined,
|
||||
} as CSSProperties
|
||||
|
||||
return (
|
||||
|
||||
@@ -64,7 +64,7 @@ export function ChartSuggestionsDialog({
|
||||
console.error('[ChartSuggestionsDialog] Error:', err)
|
||||
setResponse({
|
||||
suggestions: [],
|
||||
analyzedText: textToAnalyze?.substring(0, 100) || '',
|
||||
analyzedText: (selection || content || '').substring(0, 100),
|
||||
detectedData: 'Error occurred',
|
||||
hasData: false,
|
||||
error: err.message || 'Network error - check console',
|
||||
@@ -124,7 +124,7 @@ export function ChartSuggestionsDialog({
|
||||
<div className="flex items-center gap-3">
|
||||
<BarChart3 className="w-5 h-5 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
|
||||
<h2 className="text-lg font-semibold">{t('chart.suggestionsTitle') || 'Suggestions de graphiques'}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -137,6 +137,9 @@ export function ChartSuggestionsDialog({
|
||||
t('chart.noDataDetected')
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
{t('chart.creditCostHint') || 'Coût estimé : 2 crédits IA'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -576,8 +576,10 @@ export function BillingPlans() {
|
||||
>
|
||||
{packLoading === pack.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
) : (
|
||||
) : pack.configured ? (
|
||||
t('billing.buyPack')
|
||||
) : (
|
||||
t('billing.packNotConfigured') || 'Config Stripe requise'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -40,9 +40,21 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
|
||||
const localTheme = localStorage.getItem('theme-preference')
|
||||
const effectiveTheme = normalizeThemeId(localTheme || theme || 'light')
|
||||
|
||||
// Persiste aussi le cookie pour les prochains rendus serveur (anti-flash navigation)
|
||||
// Appliquer le thème AVANT paint (cookie + class dark atomique)
|
||||
applyDocumentTheme(effectiveTheme)
|
||||
persistThemePreference(effectiveTheme)
|
||||
|
||||
// color-scheme navigateur (inputs, scrollbars) — évite flash chrome clair
|
||||
try {
|
||||
const dark =
|
||||
effectiveTheme === 'dark' ||
|
||||
effectiveTheme === 'midnight' ||
|
||||
(effectiveTheme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
root.style.colorScheme = dark ? 'dark' : 'light'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
applyFontSize(fontSize)
|
||||
|
||||
const localFontFamily = localStorage.getItem('font-family')
|
||||
|
||||
36
memento-note/docs/dashboard-audit-2026-07-16.md
Normal file
36
memento-note/docs/dashboard-audit-2026-07-16.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Audit dashboard Second Brain (`/home`) — 2026-07-16
|
||||
|
||||
## Portée
|
||||
|
||||
Revue code de `dashboard-view.tsx` + widgets catalogue / paths / briefing.
|
||||
|
||||
## Points sains
|
||||
|
||||
| Zone | Constat |
|
||||
|------|---------|
|
||||
| Layout v5 configurable | Catalogue widgets + `/api/dashboard/layout` |
|
||||
| Prochaines pistes | Fast paths + enrich async `/api/briefing/paths` |
|
||||
| Revue quotidienne | Checklist interactive (pas revue auto) |
|
||||
| Quotas | `emitAiUsageChanged` + consent IA sur actions |
|
||||
| Aide « ? » | `DashboardWidgetHelp` i18n |
|
||||
| Empty states | Widgets catalogue gèrent listes vides |
|
||||
|
||||
## Risques / écarts
|
||||
|
||||
| # | Problème | Sévérité | Action |
|
||||
|---|----------|----------|--------|
|
||||
| 1 | Briefing dépend du cron / première visite : widgets peuvent rester vides temporairement | Moyenne | UX « chargement / aucune donnée » déjà partielle — dogfood |
|
||||
| 2 | Bridges / clusters sémantiques absurdes cross-domaine | Moyenne | Produit insights (hors dashboard pur) |
|
||||
| 3 | Widget usage encore mentalité « seaux » vs solde crédits | Basse | `DashboardUsageWidget` lit `/api/usage/current` (balance multi) — OK si API à jour |
|
||||
| 4 | Agent suggestions vides si cron agent-suggestions off | Basse | Ops CRON_SECRET |
|
||||
|
||||
## Verdict
|
||||
|
||||
Dashboard **utilisable** et aligné prototype sur l’architecture. Pas de bug bloquant code identifié dans cette passe.
|
||||
**Reste dogfood** : première visite, dark mode, clics paths, quotas 402.
|
||||
|
||||
## Suite recommandée
|
||||
|
||||
1. Dogfood 10 min sur compte réel
|
||||
2. Si widget usage affiche encore d’anciens seaux → forcer refresh API (déjà multi-feature dans usage-current)
|
||||
3. Prioriser pertinence bridges (insights) plutôt que refonte dashboard
|
||||
@@ -42,7 +42,8 @@ const LANG_NAMES: Record<string, string> = {
|
||||
function wordTargetsForLevel(level?: string): { min: number; max: number; label: string } {
|
||||
const l = (level || '').toLowerCase()
|
||||
if (/expert|avancé|advanced|fort/.test(l)) {
|
||||
return { min: 400, max: 800, label: 'expert (400–800 words per note)' }
|
||||
// Borné pour éviter timeouts JSON / mode expert qui plantait
|
||||
return { min: 350, max: 700, label: 'expert (350–700 words per note, dense)' }
|
||||
}
|
||||
if (/intermédiaire|intermediate|moyen/.test(l)) {
|
||||
return { min: 300, max: 550, label: 'intermediate (300–550 words per note)' }
|
||||
@@ -240,7 +241,13 @@ Return ONLY:
|
||||
Rules:
|
||||
- Exactly ${count} titles, progressive learning order.
|
||||
- Titles in ${this.langName(lang)} only.
|
||||
- notebookName ≠ full user sentence.`
|
||||
- notebookName ≠ full user sentence (3–8 words, concrete, topic-specific).
|
||||
- FORBIDDEN generic notebook names / chapter patterns (any language):
|
||||
"Fondements", "Foundations", "Maîtrise", "Mastery", "Introduction",
|
||||
"Basics", "Overview", "Getting started", "Chapitre 1", "Chapter 1",
|
||||
"Partie 1", "Bases", "Notions de base", "Synthèse finale" alone.
|
||||
- Prefer specific titles that name the real subject (e.g. topic keywords + angle).
|
||||
- Each title must be unique and non-interchangeable with another topic.`
|
||||
|
||||
const raw = await provider.generateText(prompt)
|
||||
const parsed = this.parseJsonObject(raw)
|
||||
@@ -250,10 +257,41 @@ Rules:
|
||||
while (titles.length < count) {
|
||||
titles.push(lang === 'fr' ? `Chapitre ${titles.length + 1}` : `Chapter ${titles.length + 1}`)
|
||||
}
|
||||
return {
|
||||
notebookName: String(parsed?.notebookName || '').trim().slice(0, 80),
|
||||
titles,
|
||||
let notebookName = String(parsed?.notebookName || '').trim().slice(0, 80)
|
||||
if (this.isGenericNotebookName(notebookName, topic)) {
|
||||
notebookName = this.deriveNotebookName(topic, titles.map((t) => ({ title: t, content: '' })), lang)
|
||||
}
|
||||
const cleanedTitles = titles.map((t, i) =>
|
||||
this.isGenericChapterTitle(t) ? this.fallbackChapterTitle(topic, i, lang) : t,
|
||||
)
|
||||
return {
|
||||
notebookName,
|
||||
titles: cleanedTitles,
|
||||
}
|
||||
}
|
||||
|
||||
private isGenericNotebookName(name: string, topic: string): boolean {
|
||||
const n = (name || '').trim().toLowerCase()
|
||||
if (!n || n.length < 3) return true
|
||||
if (n === topic.trim().toLowerCase() && topic.trim().split(/\s+/).length > 10) return true
|
||||
const banned =
|
||||
/^(fondements?|foundations?|ma[iî]trise|mastery|introduction|bases?|basics?|overview|getting started|notions de base|synth[eè]se|summary)$/i
|
||||
return banned.test(n)
|
||||
}
|
||||
|
||||
private isGenericChapterTitle(title: string): boolean {
|
||||
const t = (title || '').trim()
|
||||
if (!t) return true
|
||||
return /^(chapitre|chapter|partie|part|section)\s*\d+$/i.test(t)
|
||||
|| /^(fondements?|foundations?|ma[iî]trise|introduction|bases?|basics?|overview)$/i.test(t)
|
||||
}
|
||||
|
||||
private fallbackChapterTitle(topic: string, index: number, lang: string): string {
|
||||
const short = topic.trim().slice(0, 48) || (lang === 'fr' ? 'Sujet' : 'Topic')
|
||||
const anglesFr = ['Concepts clés', 'Méthodes', 'Exemples guidés', 'Pièges fréquents', 'Mise en pratique', 'Bilan']
|
||||
const anglesEn = ['Key concepts', 'Methods', 'Guided examples', 'Common pitfalls', 'Practice', 'Recap']
|
||||
const angles = lang === 'fr' ? anglesFr : anglesEn
|
||||
return `${angles[index % angles.length]} — ${short}`
|
||||
}
|
||||
|
||||
private async generateOneNote(
|
||||
|
||||
@@ -229,11 +229,13 @@ Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides).
|
||||
|
||||
Règles (DeckForge + think-cell):
|
||||
- Max ${maxSlides} slides
|
||||
- Headline ASSERTIVE, max 10 mots (pas "Contexte", "Introduction")
|
||||
- keyPoints: 2–5 faits de la note par slide
|
||||
- Headline ASSERTIVE, max 10 mots (INTERDIT: "Contexte", "Introduction", "Présentation", "Overview", "Points clés", "Conclusion" seuls)
|
||||
- keyPoints: 2–5 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
|
||||
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
|
||||
- Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||
- Slide 1 = title, dernière = summary
|
||||
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
|
||||
${mathRule}
|
||||
Réponds en JSON OutlineSchema.`
|
||||
}
|
||||
@@ -242,11 +244,13 @@ Output ONLY outline JSON (not full slide bodies).
|
||||
|
||||
Rules:
|
||||
- Max ${maxSlides} slides
|
||||
- ASSERTIVE headlines, max 10 words
|
||||
- keyPoints: 2–5 facts from the note per slide
|
||||
- ASSERTIVE headlines, max 10 words (FORBIDDEN alone: Context, Introduction, Overview, Key points, Conclusion)
|
||||
- keyPoints: 2–5 CONCRETE facts from the note (numbers, names, formulas) — no filler
|
||||
- One actionable idea per slide (title = insight, not section label)
|
||||
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||
- First=title, last=summary
|
||||
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
|
||||
${mathRule}
|
||||
JSON OutlineSchema only.`
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ export function applyDocumentTheme(theme: string): void {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
try {
|
||||
root.style.colorScheme = wantsDark ? 'dark' : 'light'
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// data-theme pour les palettes nommées uniquement
|
||||
if (t === 'auto' || t === 'dark' || t === 'light') {
|
||||
root.removeAttribute('data-theme')
|
||||
|
||||
@@ -128,7 +128,8 @@ export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
|
||||
display,
|
||||
amount,
|
||||
currency,
|
||||
configured: configured || isMock,
|
||||
// true seulement si un vrai price_… est en admin/env (pas les mocks)
|
||||
configured,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ export const CREDIT_COSTS: Record<string, number> = {
|
||||
brainstorm_create: 5,
|
||||
brainstorm_expand: 1,
|
||||
brainstorm_enrich: 1,
|
||||
suggest_charts: 1,
|
||||
suggest_charts: 2,
|
||||
publish_enhance: 3,
|
||||
ai_flashcard: 3,
|
||||
voice_transcribe: 1,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const THEME_INIT_SCRIPT = `(function () {
|
||||
}
|
||||
if (wantsDark) root.classList.add('dark');
|
||||
else root.classList.remove('dark');
|
||||
try { root.style.colorScheme = wantsDark ? 'dark' : 'light'; } catch (e) {}
|
||||
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
|
||||
root.removeAttribute('data-theme');
|
||||
} else {
|
||||
|
||||
@@ -3305,6 +3305,7 @@
|
||||
"packMName": "Standard pack",
|
||||
"packLName": "Power pack",
|
||||
"buyPack": "Buy",
|
||||
"packNotConfigured": "Stripe config required",
|
||||
"packCheckoutSuccess": "Credit pack added to your balance!",
|
||||
"packCheckoutFailed": "Could not start pack purchase. Check Stripe config or try again.",
|
||||
"creditsWhereTitle": "Credit usage by feature",
|
||||
@@ -4527,6 +4528,8 @@
|
||||
"invalidDescription": "This chart block contains invalid or empty data.",
|
||||
"editSource": "Edit chart source",
|
||||
"suggestions": "Chart Suggestions",
|
||||
"suggestionsTitle": "Chart suggestions",
|
||||
"creditCostHint": "Estimated cost: 2 AI credits",
|
||||
"analyzing": "Analyzing your content for chart data…",
|
||||
"debugInfo": "Debug info",
|
||||
"escCancel": "Esc Cancel",
|
||||
|
||||
@@ -3311,6 +3311,7 @@
|
||||
"packMName": "Pack standard",
|
||||
"packLName": "Pack intensif",
|
||||
"buyPack": "Acheter",
|
||||
"packNotConfigured": "Config Stripe requise",
|
||||
"packCheckoutSuccess": "Pack de crédits ajouté à votre solde !",
|
||||
"packCheckoutFailed": "Échec de l’achat du pack. Vérifiez la config Stripe ou réessayez.",
|
||||
"creditsWhereTitle": "Utilisation des crédits par fonction",
|
||||
@@ -4533,6 +4534,8 @@
|
||||
"invalidDescription": "Ce bloc de graphique contient des données invalides ou vides.",
|
||||
"editSource": "Modifier le code source",
|
||||
"suggestions": "Suggestions de graphiques",
|
||||
"suggestionsTitle": "Suggestions de graphiques",
|
||||
"creditCostHint": "Coût estimé : 2 crédits IA",
|
||||
"analyzing": "Analyse de votre contenu pour des données graphiques…",
|
||||
"debugInfo": "Infos de débogage",
|
||||
"escCancel": "Échap Annuler",
|
||||
|
||||
Reference in New Issue
Block a user