feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -141,12 +141,12 @@ export function InteractivePagePublishDialog({
|
||||
setProgress(null)
|
||||
return
|
||||
}
|
||||
// Guard: empty content from editor race
|
||||
// Guard: empty content from editor race (aligned with API min 40 words)
|
||||
const wordCount = content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
if (wordCount < 30) {
|
||||
if (wordCount < 40) {
|
||||
setPhase('error')
|
||||
setError(
|
||||
t('richTextEditor.publishInteractivePageTooShort') ||
|
||||
|
||||
@@ -4,6 +4,17 @@ import { PageView } from '@/components/interactive-page/page-view'
|
||||
import { validateInteractivePage, type PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
|
||||
const STRINGS = {
|
||||
fr: {
|
||||
stale: 'Le contenu source a évolué — cette page interactive est à régénérer.',
|
||||
invalid: 'Page interactive indisponible',
|
||||
},
|
||||
en: {
|
||||
stale: 'The source content has changed — this interactive page needs regeneration.',
|
||||
invalid: 'Interactive page unavailable',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Public / preview shell for published interactive pages.
|
||||
* Parses stored PageSpecV1 JSON from `publishedContent`.
|
||||
@@ -17,20 +28,26 @@ export function InteractivePublishedPage({
|
||||
}) {
|
||||
let page: PageSpecV1 | null = null
|
||||
let error: string | null = null
|
||||
let lang = 'fr'
|
||||
try {
|
||||
const raw = JSON.parse(publishedContent)
|
||||
const result = validateInteractivePage(raw)
|
||||
if (result.ok) page = result.page
|
||||
else error = result.issues[0]?.message || 'PageSpec invalide'
|
||||
if (result.ok) {
|
||||
page = result.page
|
||||
lang = result.page.lang || 'fr'
|
||||
} else {
|
||||
error = result.issues[0]?.message || 'PageSpec invalide'
|
||||
}
|
||||
} catch {
|
||||
error = 'JSON de page interactive illisible'
|
||||
}
|
||||
const t = lang.startsWith('fr') ? STRINGS.fr : STRINGS.en
|
||||
|
||||
if (!page) {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-lg gap-3 p-10 text-sm text-destructive">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<p>{error || 'Page interactive indisponible'}</p>
|
||||
<p>{error || t.invalid}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -39,7 +56,7 @@ export function InteractivePublishedPage({
|
||||
<div>
|
||||
{isStale ? (
|
||||
<div className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-center text-xs text-amber-800 dark:text-amber-200">
|
||||
Le contenu source a évolué — cette page interactive est à régénérer.
|
||||
{t.stale}
|
||||
</div>
|
||||
) : null}
|
||||
<PageView page={page} demoMode="interactive" />
|
||||
|
||||
@@ -17,6 +17,7 @@ import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { PageFormula, PageMd } from '@/components/interactive-page/page-md'
|
||||
import { SimBlockView } from '@/components/interactive-page/sim-block'
|
||||
import { StepsBlockView } from '@/components/interactive-page/steps-block'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
|
||||
import type { PageBlock } from '@/lib/interactive-page'
|
||||
@@ -25,16 +26,20 @@ import { cn } from '@/lib/utils'
|
||||
/** Intents actually used inside a demo (legend per demo, brainstorm P11). */
|
||||
function collectDemoIntents(demo: InteractiveDemoV1): IntentId[] {
|
||||
const set = new Set<IntentId>()
|
||||
for (const panel of demo.scene.panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
}
|
||||
if (panel.type === 'chart') {
|
||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||
const collectFromPanels = (panels: InteractiveDemoV1['scene']['panels']) => {
|
||||
for (const panel of panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
}
|
||||
if (panel.type === 'chart') {
|
||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
collectFromPanels(demo.scene.panels)
|
||||
for (const act of demo.acts) {
|
||||
if (act.scene) collectFromPanels(act.scene.panels)
|
||||
for (const step of act.steps) {
|
||||
for (const a of step.annotate ?? []) if (a.intent) set.add(a.intent)
|
||||
}
|
||||
@@ -280,6 +285,10 @@ export function PageBlockView({
|
||||
return <SimBlockView block={block} lang={lang} />
|
||||
}
|
||||
|
||||
if (block.type === 'steps') {
|
||||
return <StepsBlockView block={block} lang={lang} />
|
||||
}
|
||||
|
||||
if (block.type === 'chart') {
|
||||
return <ChartBlockView block={block} />
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ export function PageMd({
|
||||
|
||||
let out = marked.parse(withSlots, { gfm: true, breaks: true }) as string
|
||||
placeholders.forEach((frag, i) => {
|
||||
out = out.replace(`%%KATEX${i}%%`, frag)
|
||||
// function replacement — $', $`, $& in KaTeX HTML must not be interpreted
|
||||
out = out.replace(`%%KATEX${i}%%`, () => frag)
|
||||
})
|
||||
return sanitizeRichHtml(out)
|
||||
}, [md])
|
||||
|
||||
@@ -25,7 +25,11 @@ function collectIntents(page: PageSpecV1): IntentId[] {
|
||||
}
|
||||
}
|
||||
if (block.type === 'demo') {
|
||||
for (const panel of block.demo.scene.panels) {
|
||||
const panels = [
|
||||
...block.demo.scene.panels,
|
||||
...block.demo.acts.flatMap((a) => a.scene?.panels ?? []),
|
||||
]
|
||||
for (const panel of panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
|
||||
@@ -79,6 +79,9 @@ export function PageView({
|
||||
style={paperStyle}
|
||||
>
|
||||
<style>{`
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html { scroll-behavior: smooth; }
|
||||
}
|
||||
.interactive-page {
|
||||
--pp-paper: #F4F0E8;
|
||||
--pp-paper-deep: #EAE4D9;
|
||||
@@ -139,6 +142,15 @@ export function PageView({
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
{/* Without JS the scroll-reveal never fires — show everything */}
|
||||
<noscript>
|
||||
<style>{`
|
||||
.interactive-page [data-scroll-init] {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
`}</style>
|
||||
</noscript>
|
||||
|
||||
{/* ── Hero (kicker / 800 title / ink subtitle / mono meta) ── */}
|
||||
<header className="mx-auto max-w-[1100px] px-5 pb-8 pt-12 md:pt-16">
|
||||
|
||||
134
memento-note/components/interactive-page/steps-block.tsx
Normal file
134
memento-note/components/interactive-page/steps-block.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import katex from 'katex'
|
||||
import { AnimPlayerShell } from '@/components/simulators/anim-player-shell'
|
||||
import type { StepsBlock } from '@/lib/interactive-page'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function renderTex(tex: string, displayMode: boolean): string {
|
||||
try {
|
||||
return katex.renderToString(tex, { displayMode, throwOnError: false })
|
||||
} catch {
|
||||
return tex
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-by-step derivation (Symbolab/Khan style): equation states revealed
|
||||
* line by line, current line highlighted, transformation rule in the margin.
|
||||
* No boxes, no LLM drawing — pure KaTeX + deterministic chrome.
|
||||
*/
|
||||
export function StepsBlockView({
|
||||
block,
|
||||
lang,
|
||||
}: {
|
||||
block: StepsBlock
|
||||
lang: string
|
||||
}) {
|
||||
const fr = lang.startsWith('fr')
|
||||
const steps = block.steps
|
||||
|
||||
const beats = useMemo(
|
||||
() =>
|
||||
steps.map((s, i) => ({
|
||||
id: `st${i + 1}`,
|
||||
speak: {
|
||||
fr: s.speak || s.rule || (fr ? `Étape ${i + 1}` : `Step ${i + 1}`),
|
||||
en: s.speak || s.rule || `Step ${i + 1}`,
|
||||
},
|
||||
})),
|
||||
[steps, fr]
|
||||
)
|
||||
|
||||
const rendered = useMemo(() => steps.map((s) => renderTex(s.tex, true)), [steps])
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="my-8 rounded-2xl border p-4 md:p-5"
|
||||
style={{ background: 'var(--pp-card)', borderColor: 'var(--pp-line)' }}
|
||||
>
|
||||
{block.title ? (
|
||||
<div className="mb-4 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold tracking-tight">{block.title}</h3>
|
||||
<span
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.16em]"
|
||||
style={{ color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{fr ? 'Dérivation pas à pas' : 'Step-by-step derivation'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AnimPlayerShell beats={beats} lang={lang}>
|
||||
{(stepIndex) => (
|
||||
<div className="space-y-1.5 p-3 md:p-4" style={{ background: 'var(--pp-paper)' }}>
|
||||
{steps.slice(0, stepIndex + 1).map((s, i) => {
|
||||
const current = i === stepIndex
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border-l-4 px-3 py-2',
|
||||
current ? 'shadow-sm' : 'border-transparent'
|
||||
)}
|
||||
style={{
|
||||
borderLeftColor: current ? 'var(--pp-plum)' : 'transparent',
|
||||
background: current ? 'var(--pp-card)' : 'transparent',
|
||||
opacity: current ? 1 : 0.72,
|
||||
transition: 'opacity 400ms ease, background 400ms ease',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="mt-1 shrink-0 text-[10px] font-semibold tabular-nums"
|
||||
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
|
||||
>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div
|
||||
className="min-w-0 flex-1 overflow-x-auto text-[1.02em] [&_.katex-display]:my-1"
|
||||
dangerouslySetInnerHTML={{ __html: rendered[i] }}
|
||||
/>
|
||||
{s.rule ? (
|
||||
<span
|
||||
className="mt-0.5 shrink-0 rounded-md px-2 py-1 text-[11px] leading-snug"
|
||||
style={{
|
||||
fontFamily: 'var(--pp-mono)',
|
||||
color: 'var(--pp-plum)',
|
||||
background: 'color-mix(in oklab, var(--pp-plum) 10%, transparent)',
|
||||
maxWidth: '38%',
|
||||
}}
|
||||
>
|
||||
{s.rule}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</AnimPlayerShell>
|
||||
|
||||
{block.caption ? (
|
||||
<figcaption
|
||||
className="mt-3 text-center text-sm"
|
||||
style={{ color: 'var(--pp-muted)' }}
|
||||
>
|
||||
{block.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
|
||||
{/* Without JS: full derivation visible + rules listed */}
|
||||
<noscript>
|
||||
<ol className="mt-3 list-inside list-decimal space-y-2 text-sm">
|
||||
{steps.map((s, i) => (
|
||||
<li key={i}>
|
||||
<span dangerouslySetInnerHTML={{ __html: rendered[i] }} />
|
||||
{s.rule ? <em className="ml-2 text-muted-foreground">({s.rule})</em> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</noscript>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user