fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m55s
CI / Deploy production (on server) (push) Successful in 36s

Centralise la réserve via ai-quota, corrige admin unavailable (-1), brancher les routes sans quota et le host-pays brainstorm, avec usage-meter élargi, noms de clusters, MCP et ajustements dashboard/insights.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-15 20:42:25 +00:00
parent 30da592ba2
commit 4fe31ebc99
75 changed files with 2949 additions and 785 deletions

View File

@@ -76,9 +76,11 @@ export function DashboardAgentCarousel({
/>
{suggestions.length === 0 ? (
<p className="text-[11px] text-concrete italic leading-relaxed py-2">
{t('homeDashboard.agentsEmpty')}
</p>
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] text-concrete leading-relaxed">
{t('homeDashboard.agentsEmpty')}
</p>
</div>
) : (
<AgentSlide
current={suggestions[idx]}

View File

@@ -294,6 +294,7 @@ export function DashboardActivityWidget({
loading: boolean
}) {
const { t } = useLanguage()
const hasActivity = data.some(d => d.count > 0)
return (
<DashboardWidgetShell
widgetId="activity"
@@ -301,11 +302,17 @@ export function DashboardActivityWidget({
title={t('homeDashboard.widgets.activity')}
>
{loading ? (
<div className="h-24 rounded-lg bg-stone-50 animate-pulse" />
<div className="h-24 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
) : !hasActivity ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-4 text-center">
<p className="text-[10px] text-concrete leading-relaxed">{t('homeDashboard.activityEmptyHint')}</p>
</div>
) : (
<RevisionHeatmap data={data} />
<>
<RevisionHeatmap data={data} />
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</>
)}
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
</DashboardWidgetShell>
)
}
@@ -329,16 +336,20 @@ export function DashboardFlashcardsProgressWidget({
retentionRate,
streak,
totalCards,
dueCount = 0,
loading,
onOpen,
}: {
retentionRate: number
streak: number
totalCards: number
dueCount?: number
loading?: boolean
onOpen: () => void
}) {
const { t } = useLanguage()
const retention = Math.max(0, Math.min(100, retentionRate))
return (
<DashboardWidgetShell
widgetId="flashcards-progress"
@@ -347,12 +358,28 @@ export function DashboardFlashcardsProgressWidget({
compact
>
{loading ? (
<div className="h-14 rounded-lg bg-stone-50 animate-pulse" />
<div className="space-y-2">
<div className="h-12 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-8 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : totalCards === 0 && dueCount === 0 ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3 text-center">
<p className="text-[10px] text-concrete leading-relaxed mb-2">
{t('homeDashboard.flashEmptyHint')}
</p>
<button
type="button"
onClick={onOpen}
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
>
{t('homeDashboard.flashEmptyCta')}
</button>
</div>
) : (
<button type="button" onClick={onOpen} className="w-full text-start">
<div className="grid grid-cols-3 gap-2">
<button type="button" onClick={onOpen} className="w-full text-start group">
<div className="grid grid-cols-3 gap-2 mb-2.5">
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retentionRate}%</p>
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{retention}%</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashRetention')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
@@ -360,10 +387,25 @@ export function DashboardFlashcardsProgressWidget({
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashStreak')}</p>
</div>
<div className="text-center p-2 rounded-xl border border-border/20">
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards}</p>
<p className="text-lg font-serif font-bold text-ink dark:text-dark-ink">{totalCards || dueCount}</p>
<p className="text-[8px] font-mono uppercase text-concrete">{t('homeDashboard.flashTotal')}</p>
</div>
</div>
<div className="h-1.5 rounded-full bg-stone-100 dark:bg-zinc-800 overflow-hidden mb-2">
<div
className="h-full rounded-full bg-brand-accent transition-all"
style={{ width: `${retention}%` }}
/>
</div>
{dueCount > 0 ? (
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
{t('homeDashboard.flashDueCta', { count: dueCount })}
</p>
) : (
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors">
{t('homeDashboard.flashOpenCta')}
</p>
)}
</button>
)}
</DashboardWidgetShell>

View File

@@ -1,7 +1,7 @@
'use client'
import { motion } from 'motion/react'
import { Clock, ChevronRight, Play } from 'lucide-react'
import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
@@ -19,6 +19,7 @@ export interface DashboardResumeHeroProps {
notes: ResumeNote[]
loading?: boolean
onSelect: (id: string, notebookId: string | null) => void
onCaptureFocus?: () => void
formatRelativeTime: (date: string) => string
prefersReducedMotion?: boolean
}
@@ -27,28 +28,24 @@ export function DashboardResumeHero({
notes,
loading,
onSelect,
onCaptureFocus,
formatRelativeTime,
prefersReducedMotion,
}: DashboardResumeHeroProps) {
const { t } = useLanguage()
const hero = notes[0]
const rest = notes.slice(1, 4)
const rest = notes.slice(1, 6)
if (loading) {
return (
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[200px] animate-pulse">
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[220px] animate-pulse">
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
</div>
)
}
if (!hero) {
return (
<div className="rounded-2xl border border-dashed border-border/40 bg-white/50 dark:bg-zinc-900/50 p-8 text-center">
<Clock size={24} className="mx-auto text-concrete/30 mb-3" strokeWidth={1.25} />
<p className="text-xs text-concrete italic">{t('homeDashboard.noRecentNotes')}</p>
<div className="h-16 bg-stone-50 dark:bg-zinc-950 rounded-xl mb-3" />
<div className="space-y-2">
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
</div>
</div>
)
}
@@ -64,59 +61,93 @@ export function DashboardResumeHero({
/>
</div>
<motion.button
type="button"
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
onClick={() => onSelect(hero.id, hero.notebookId)}
className="w-full text-start px-5 pb-5 group cursor-pointer"
>
<div
className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all"
>
<div className="flex items-start justify-between gap-3 mb-2">
<span
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
style={{ backgroundColor: hero.notebookColor }}
>
{hero.notebookName}
</span>
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
{formatRelativeTime(hero.updatedAt)}
</span>
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
{hero.title || t('homeDashboard.untitled')}
</h3>
{hero.excerpt && (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
{hero.excerpt}
{!hero ? (
<div className="px-5 pb-5">
<div className="rounded-xl border border-dashed border-border/40 bg-stone-50/60 dark:bg-zinc-950/40 p-5 text-center">
<Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
<p className="text-xs text-concrete mb-3 leading-relaxed">
{t('homeDashboard.resumeEmptyHint')}
</p>
)}
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-0 group-hover:opacity-100 transition-opacity">
{t('homeDashboard.resumeOpen')}
<ChevronRight size={12} />
{onCaptureFocus && (
<button
type="button"
onClick={onCaptureFocus}
className="inline-flex items-center gap-1.5 text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
>
<PenLine size={11} />
{t('homeDashboard.resumeEmptyCta')}
</button>
)}
</div>
</div>
</motion.button>
) : (
<>
<motion.button
type="button"
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
onClick={() => onSelect(hero.id, hero.notebookId)}
className="w-full text-start px-5 pb-3 group cursor-pointer"
>
<div className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all">
<div className="flex items-start justify-between gap-3 mb-2">
<span
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
style={{ backgroundColor: hero.notebookColor }}
>
{hero.notebookName}
</span>
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
{formatRelativeTime(hero.updatedAt)}
</span>
</div>
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
{hero.title || t('homeDashboard.untitled')}
</h3>
{hero.excerpt ? (
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
{hero.excerpt}
</p>
) : null}
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-80 group-hover:opacity-100 transition-opacity">
{t('homeDashboard.resumeOpen')}
<ChevronRight size={12} />
</div>
</div>
</motion.button>
{rest.length > 0 && (
<div className="flex gap-2 px-5 pb-4 overflow-x-auto custom-scrollbar">
{rest.map(note => (
<button
key={note.id}
type="button"
onClick={() => onSelect(note.id, note.notebookId)}
className="shrink-0 max-w-[200px] p-2.5 rounded-xl border border-border/20 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-brand-accent/30 transition-all text-start group"
>
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{note.title || t('homeDashboard.untitled')}
{rest.length > 0 && (
<div className="px-5 pb-4 space-y-1.5">
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70 mb-1">
{t('homeDashboard.resumeAlso')}
</p>
<p className="text-[8px] font-mono text-concrete/60 mt-0.5">
{formatRelativeTime(note.updatedAt)}
</p>
</button>
))}
</div>
{rest.map(note => (
<button
key={note.id}
type="button"
onClick={() => onSelect(note.id, note.notebookId)}
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
>
<span
className="w-1.5 h-8 rounded-full shrink-0"
style={{ backgroundColor: note.notebookColor }}
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
{note.title || t('homeDashboard.untitled')}
</p>
<p className="text-[9px] text-concrete truncate mt-0.5">
{note.notebookName}
{' · '}
{formatRelativeTime(note.updatedAt)}
</p>
</div>
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
</button>
))}
</div>
)}
</>
)}
</div>
)

View File

@@ -29,6 +29,7 @@ import {
} from '@/components/dashboard-path-widgets'
import type { DashboardPath } from '@/lib/dashboard/path-types'
import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
import {
DashboardInboxWidget,
DashboardRevisionWidget,
@@ -192,7 +193,8 @@ function localeForLanguage(language: string): string {
return map[language] || language
}
function stripHtml(html: string): string {
function stripHtml(html: string | null | undefined): string {
if (!html) return ''
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
}
@@ -293,7 +295,10 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
const loadSentiment = useCallback(async () => {
try {
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
if (res.ok) setSentiment(await res.json())
if (res.ok) {
setSentiment(await res.json())
emitAiUsageChanged()
}
} catch {
/* widget sentiment en état vide */
} finally {
@@ -322,15 +327,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
fetch('/api/flashcards/stats', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null)
.then(json => {
if (json) {
setFlashcardStats({
retentionRate: json.retentionRate ?? 0,
streak: json.streak ?? 0,
totalCards: json.totalCards ?? 0,
})
}
setFlashcardStats({
retentionRate: json?.retentionRate ?? 0,
streak: json?.streak ?? 0,
totalCards: json?.totalCards ?? 0,
})
})
.catch(() => {
setFlashcardStats({ retentionRate: 0, streak: 0, totalCards: 0 })
})
.catch(() => {})
}, [loadBriefing, loadMindMap])
// Pistes rapides dès le briefing, puis enrichissement en arrière-plan
@@ -482,6 +487,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
if (!res.ok) throw new Error(json.error)
if (json.insight) {
await Promise.all([loadBriefing(), loadPaths()])
emitAiUsageChanged()
toast.success(t('homeDashboard.echoFound'))
} else {
toast.info(t('homeDashboard.echoNone'))
@@ -789,6 +795,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
retentionRate={flashcardStats?.retentionRate ?? 0}
streak={flashcardStats?.streak ?? 0}
totalCards={flashcardStats?.totalCards ?? 0}
dueCount={dueFlashcards}
loading={!flashcardStats}
onOpen={() => router.push('/revision')}
/>,
@@ -799,6 +806,14 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
notes={resumeNotes}
loading={briefingLoading}
onSelect={onNoteSelect}
onCaptureFocus={() => {
document.getElementById('dashboard-widget-capture')?.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'center',
})
const ta = document.querySelector<HTMLTextAreaElement>('#dashboard-widget-capture textarea')
ta?.focus()
}}
formatRelativeTime={relTime}
prefersReducedMotion={!!prefersReducedMotion}
/>,
@@ -1005,7 +1020,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
<div className="h-full w-full bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-y-auto custom-scrollbar relative">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-10 py-6 sm:py-8 relative z-10">
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
{/* ── En-tête : orientation en 2 secondes ── */}
<header className="mb-5">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">

View File

@@ -151,7 +151,20 @@ export function DashboardWidgetGrid({ renderWidget }: DashboardWidgetGridProps)
fetch('/api/dashboard/layout', { cache: 'no-store' })
.then(r => r.ok ? r.json() : null)
.then(json => {
if (json?.layout) setLayout(normalizeDashboardLayout(json.layout))
if (json?.layout) {
const normalized = normalizeDashboardLayout(json.layout)
setLayout(normalized)
const incomingVersion = typeof (json.layout as { version?: number })?.version === 'number'
? (json.layout as { version: number }).version
: 0
if (incomingVersion < normalized.version) {
fetch('/api/dashboard/layout', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ layout: normalized }),
}).catch(() => {})
}
}
})
.catch(() => {})
.finally(() => setLoaded(true))

View File

@@ -41,9 +41,16 @@ import { cn } from '@/lib/utils'
interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus
mcpAllowed?: boolean
tier?: string
}
export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanelProps) {
export function McpSettingsPanel({
initialKeys,
serverStatus,
mcpAllowed = true,
tier = 'PRO',
}: McpSettingsPanelProps) {
const [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition()
@@ -69,7 +76,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
...prev,
])
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to generate key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.generate'))
}
})
}
@@ -84,7 +91,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
)
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to revoke key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.revoke'))
}
})
}
@@ -97,7 +104,7 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete key')
toast.error(err instanceof Error ? err.message : t('mcpSettings.apiKeys.errors.delete'))
}
})
}
@@ -183,6 +190,11 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
</div>
</div>
{!mcpAllowed ? (
<p className="text-[10px] text-concrete max-w-[200px] text-end leading-relaxed">
{t('mcpSettings.tierRequired', { tier })}
</p>
) : (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] hover:scale-[1.02] active:scale-95 transition-all duration-300 shadow-lg shadow-ink/20">
@@ -192,8 +204,17 @@ export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanel
</DialogTrigger>
<CreateKeyDialog onGenerate={handleGenerate} isPending={isPending} />
</Dialog>
)}
</div>
{!mcpAllowed && (
<div className="px-6 pb-4">
<p className="text-[11px] text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed">
{t('mcpSettings.upgradeHint')}
</p>
</div>
)}
<div className="p-6">
{keys.length === 0 ? (
<div className="text-center py-8">

View File

@@ -1,8 +1,8 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import { Maximize2 } from 'lucide-react'
import { Maximize2, Search, ChevronDown } from 'lucide-react'
interface Note {
id: string
@@ -35,8 +35,15 @@ interface NetworkGraphProps {
untitledLabel?: string
resetFocusLabel?: string
fitViewLabel?: string
legendFilterPlaceholder?: string
legendShowMoreLabel?: string
legendShowLessLabel?: string
/** Nombre de pastilles visibles avant « voir plus » (défaut 8) */
legendPreviewCount?: number
}
const DEFAULT_LEGEND_PREVIEW = 8
export function NetworkGraph({
notes,
clusters,
@@ -47,10 +54,45 @@ export function NetworkGraph({
untitledLabel = 'Untitled',
resetFocusLabel = 'Reset focus',
fitViewLabel = 'Fit view',
legendFilterPlaceholder = 'Filter themes…',
legendShowMoreLabel = 'Show {count} more',
legendShowLessLabel = 'Show less',
legendPreviewCount = DEFAULT_LEGEND_PREVIEW,
}: NetworkGraphProps) {
const svgRef = useRef<SVGSVGElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const zoomRef = useRef<any>(null)
const [legendExpanded, setLegendExpanded] = useState(false)
const [legendFilter, setLegendFilter] = useState('')
const sortedClusters = useMemo(
() => [...clusters].sort((a, b) => b.noteIds.length - a.noteIds.length),
[clusters]
)
const filteredClusters = useMemo(() => {
const q = legendFilter.trim().toLowerCase()
if (!q) return sortedClusters
return sortedClusters.filter(c =>
(c.name ?? String(c.id)).toLowerCase().includes(q)
)
}, [sortedClusters, legendFilter])
const visibleLegendClusters = useMemo(() => {
if (legendExpanded || legendFilter.trim()) return filteredClusters
const preview = filteredClusters.slice(0, legendPreviewCount)
// Toujours garder le cluster sélectionné visible même hors top N
if (
selectedClusterId &&
!preview.some(c => String(c.id) === selectedClusterId)
) {
const selected = filteredClusters.find(c => String(c.id) === selectedClusterId)
if (selected) return [selected, ...preview.slice(0, Math.max(0, legendPreviewCount - 1))]
}
return preview
}, [filteredClusters, legendExpanded, legendFilter, legendPreviewCount, selectedClusterId])
const hiddenLegendCount = Math.max(0, filteredClusters.length - visibleLegendClusters.length)
useEffect(() => {
if (!svgRef.current || !containerRef.current) return
@@ -362,43 +404,88 @@ export function NetworkGraph({
return (
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
{/* Pastilles de cluster — triées par taille, avec compteurs */}
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-1.5 max-w-[85%]">
{[...clusters]
.sort((a, b) => b.noteIds.length - a.noteIds.length)
.map(c => {
const isSelected = String(c.id) === selectedClusterId
return (
<button
key={c.id}
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border transition-all text-[9px] font-bold uppercase tracking-wider cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
isSelected
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border shadow-sm'
}`}
>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="truncate max-w-[120px]">{c.name ?? String(c.id)}</span>
<span className={`text-[8px] px-1 rounded-full shrink-0 ${isSelected ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{c.noteIds.length}
</span>
</button>
)
})}
{selectedClusterId && (
<button
onClick={() => onClusterSelect?.(null)}
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm"
>
{resetFocusLabel}
</button>
{/* Pastilles de cluster — aperçu compact + filtre quand élargi */}
<div className="absolute top-6 start-6 z-10 flex flex-col gap-2 max-w-[min(420px,85%)]">
{(legendExpanded || sortedClusters.length > legendPreviewCount) && (
<div className="relative">
<Search
size={12}
className="absolute start-2.5 top-1/2 -translate-y-1/2 text-concrete pointer-events-none"
aria-hidden
/>
<input
type="search"
value={legendFilter}
onChange={e => {
setLegendFilter(e.target.value)
if (e.target.value.trim()) setLegendExpanded(true)
}}
placeholder={legendFilterPlaceholder}
aria-label={legendFilterPlaceholder}
className="w-full max-w-[260px] bg-white/95 dark:bg-black/85 border border-border/40 rounded-full ps-8 pe-3 py-1.5 text-[10px] outline-none focus:ring-2 focus:ring-ochre/30 shadow-sm backdrop-blur-sm placeholder:text-concrete/70"
/>
</div>
)}
<div className="flex flex-wrap gap-1.5 max-h-[28vh] overflow-y-auto custom-scrollbar pe-1">
{visibleLegendClusters.map(c => {
const isSelected = String(c.id) === selectedClusterId
return (
<button
key={c.id}
type="button"
onClick={() => onClusterSelect?.(isSelected ? null : String(c.id))}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full border transition-all text-[9px] font-bold uppercase tracking-wider cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none ${
isSelected
? 'bg-ink text-white dark:bg-white dark:text-black border-ink dark:border-white scale-105 shadow-md'
: 'bg-white/90 dark:bg-black/80 text-concrete hover:text-ink hover:border-concrete/40 border-border shadow-sm'
}`}
>
<div className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: c.color }} />
<span className="truncate max-w-[120px]">{c.name ?? String(c.id)}</span>
<span className={`text-[8px] px-1 rounded-full shrink-0 ${isSelected ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
{c.noteIds.length}
</span>
</button>
)
})}
{selectedClusterId && (
<button
type="button"
onClick={() => onClusterSelect?.(null)}
className="px-3 py-1.5 rounded-full border border-rose-200 bg-rose-50 dark:bg-rose-950/20 dark:border-rose-900/40 text-rose-500 text-[9px] font-bold uppercase tracking-wider hover:bg-rose-100 dark:hover:bg-rose-950/30 transition-all shadow-sm cursor-pointer"
>
{resetFocusLabel}
</button>
)}
{!legendFilter.trim() && (hiddenLegendCount > 0 || legendExpanded) && filteredClusters.length > legendPreviewCount && (
<button
type="button"
onClick={() => {
setLegendExpanded(v => !v)
if (legendExpanded) setLegendFilter('')
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-full border border-dashed border-border/60 bg-white/80 dark:bg-black/70 text-concrete hover:text-ink text-[9px] font-bold uppercase tracking-wider shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
<ChevronDown
size={11}
className={`transition-transform ${legendExpanded ? 'rotate-180' : ''}`}
aria-hidden
/>
{legendExpanded
? legendShowLessLabel
: legendShowMoreLabel.replace('{count}', String(hiddenLegendCount))}
</button>
)}
</div>
</div>
{/* Fit view button */}
<button
onClick={handleFitView}
className="absolute top-6 right-6 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border/40 bg-white/90 dark:bg-black/80 text-concrete hover:text-ink dark:hover:text-dark-ink hover:border-concrete/40 text-[9px] font-bold uppercase tracking-wider transition-all shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none backdrop-blur-sm"
className="absolute top-6 end-6 z-10 flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-border/40 bg-white/90 dark:bg-black/80 text-concrete hover:text-ink dark:hover:text-dark-ink hover:border-concrete/40 text-[9px] font-bold uppercase tracking-wider transition-all shadow-sm cursor-pointer focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none backdrop-blur-sm"
aria-label={fitViewLabel}
>
<Maximize2 size={11} />

View File

@@ -112,7 +112,7 @@ export function BillingHistory() {
{error && (
<p className="text-xs text-rose-500 text-center py-4">
Impossible de charger l'historique de facturation.
{t('billing.fetchInvoicesFailed')}
</p>
)}
@@ -152,7 +152,7 @@ export function BillingHistory() {
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center p-2 rounded-lg text-concrete hover:text-brand-accent hover:bg-brand-accent/10 transition-all"
title="Download PDF"
title={t('billing.downloadPdf')}
>
<Download size={14} />
</a>

View File

@@ -62,7 +62,7 @@ export function BillingPlans() {
queryFn: async () => {
const search = typeof window !== 'undefined' ? window.location.search : '';
const res = await fetch(`/api/billing/status${search}`);
if (!res.ok) throw new Error('Failed to fetch billing status');
if (!res.ok) throw new Error(t('billing.fetchStatusFailed'));
return res.json();
},
});
@@ -71,7 +71,7 @@ export function BillingPlans() {
queryKey: ['usage', 'current'],
queryFn: async () => {
const res = await fetch('/api/usage/current');
if (!res.ok) throw new Error('Failed to fetch quotas');
if (!res.ok) throw new Error(t('billing.fetchQuotasFailed'));
return res.json();
},
refetchInterval: 30000,
@@ -122,7 +122,7 @@ export function BillingPlans() {
}
} catch (err) {
console.error('[BillingPlans] checkout error:', err);
toast.error('Failed to start checkout. Please try again.');
toast.error(t('billing.checkoutFailed'));
} finally {
setCheckoutLoading(null);
}
@@ -139,20 +139,20 @@ export function BillingPlans() {
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to open billing portal.');
toast.error(data.error || t('billing.portalFailed'));
return;
}
window.location.href = data.url;
} catch (err) {
console.error('[BillingPlans] portal error:', err);
toast.error('Failed to open billing portal.');
toast.error(t('billing.portalFailed'));
} finally {
setPortalLoading(false);
}
};
const handleCancelSubscription = async () => {
const confirmMsg = t('billing.cancelConfirm') || "Êtes-vous sûr de vouloir résilier votre abonnement ? Vous conserverez vos accès Pro/Business jusqu'à la fin de la période en cours.";
const confirmMsg = t('billing.cancelConfirm');
if (!window.confirm(confirmMsg)) {
return;
}
@@ -165,15 +165,15 @@ export function BillingPlans() {
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to cancel subscription.');
toast.error(data.error || t('billing.cancelFailed'));
return;
}
toast.success(t('billing.cancelSuccess') || "Votre abonnement a été résilié avec succès. Il prendra fin à la fin de la période de facturation en cours.");
toast.success(t('billing.cancelSuccess'));
queryClient.invalidateQueries({ queryKey: ['billing', 'status'] });
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
} catch (err) {
console.error('[BillingPlans] cancel error:', err);
toast.error('Failed to cancel subscription.');
toast.error(t('billing.cancelFailed'));
} finally {
setCancelLoading(false);
}
@@ -229,7 +229,7 @@ export function BillingPlans() {
name: t('billing.proPlan'),
price: status?.prices?.PRO?.[interval]?.display ??
(interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€')),
period: interval === 'month' ? '/mois' : '/an',
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
t('billing.proFeature1') || 'Notes illimitées',
@@ -252,8 +252,7 @@ export function BillingPlans() {
name: t('billing.businessPlan'),
price: status?.prices?.BUSINESS?.[interval]?.display ??
(interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€')),
period: interval === 'month' ? '/mois' : '/an',
description: t('billing.businessDescription') || 'Pour les équipes et chefs de produit.',
period: interval === 'month' ? t('billing.perMonth') : t('billing.perYear'),
features: [
t('billing.businessFeature1') || '10 Collaborateurs inclus',
t('billing.businessFeature2') || 'BYOK (13 fournisseurs)',
@@ -560,7 +559,7 @@ export function BillingPlans() {
)}
>
{t('billing.annual')}
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.save')} ~17%</span>
<span className="ms-1 text-primary/80 dark:text-primary">{t('billing.savePercent')}</span>
</button>
</div>
) : (

View File

@@ -103,7 +103,7 @@ export function InlinePaywall({ feature, onDismiss }: InlinePaywallProps) {
</button>
<div className="sm:ml-auto flex items-center justify-center gap-1.5 text-[10px] text-rose-500/60 dark:text-rose-400/40 font-mono">
<span>Fermeture dans {timeLeft}s</span>
<span>{t('quotaPaywall.closingIn').replace('{n}', String(timeLeft))}</span>
</div>
</div>
</motion.div>

View File

@@ -1296,18 +1296,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
ref={asideRef}
style={sidebarWidth !== null ? { width: sidebarWidth } : undefined}
className={cn(
// Sur /insights replié : fixed hors flux pour que <main flex-1> s'agrandisse.
// Ne jamais ajouter un `relative` nu ensuite — twMerge le ferait gagner sur `fixed`
// et réserverait toujours l'espace (bande beige vide).
isImmersiveRoute && userCollapsed
? 'fixed inset-y-0 start-0 z-[70]'
: 'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
'h-full min-h-0 shrink-0 flex flex-row self-stretch relative overflow-visible',
? cn(
'fixed inset-y-0 start-0 z-[70]',
isRtl ? 'translate-x-full' : '-translate-x-full',
'pointer-events-none',
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
)
: cn(
'fixed inset-y-0 start-0 z-[70] md:relative md:z-auto',
sidebarWidth === null && 'w-80 md:w-[26rem] 2xl:w-[30rem]',
isMobileOpen
? 'translate-x-0 shadow-2xl'
: cn(isRtl ? 'translate-x-full md:translate-x-0' : '-translate-x-full md:translate-x-0'),
),
'h-full min-h-0 shrink-0 flex flex-row self-stretch overflow-visible',
'transition-transform duration-300 ease-in-out',
isImmersiveRoute && userCollapsed
? '-translate-x-full'
: (isMobileOpen ? 'translate-x-0 shadow-2xl' : '-translate-x-full md:translate-x-0'),
'border-e border-border/40 bg-white/95 md:bg-white/30 backdrop-blur-md sidebar-shadow dark:border-white/6 dark:bg-[#151515] dark:backdrop-blur-none',
className
)}
aria-hidden={isImmersiveRoute && userCollapsed ? true : undefined}
>
{/* ── Column 1 : Rail d'icônes (54px) — inspiré du prototype ── */}
<div className="w-[54px] border-e border-border/40 bg-[#FAF9F5] dark:bg-[#0E0E0E] flex flex-col items-center justify-between py-5 shrink-0 select-none overflow-hidden">

View File

@@ -69,9 +69,15 @@ export function UsageMeter({ className }: UsageMeterProps) {
'auto_title',
'reformulate',
'chat',
'brainstorm_create', // Sera affiché comme "Sessions brainstorm"
'brainstorm_create',
'brainstorm_expand',
'brainstorm_enrich',
'suggest_charts',
'publish_enhance',
'ai_flashcard',
'voice_transcribe',
'slide_generate',
'excalidraw_generate',
]);
const featureLabels: Record<string, string> = {
@@ -80,9 +86,15 @@ export function UsageMeter({ className }: UsageMeterProps) {
auto_title: t('usageMeter.featureTitles'),
reformulate: t('usageMeter.featureReformulate'),
chat: t('usageMeter.featureChat'),
brainstorm_create: t('usageMeter.featureBrainstormSessions'), // Label simplifié
brainstorm_create: t('usageMeter.featureBrainstormSessions'),
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
suggest_charts: t('usageMeter.featureCharts'),
publish_enhance: t('usageMeter.featurePublishEnhance'),
ai_flashcard: t('usageMeter.featureFlashcards'),
voice_transcribe: t('usageMeter.featureVoice'),
slide_generate: t('usageMeter.featureSlides'),
excalidraw_generate: t('usageMeter.featureDiagrams'),
};
const featureQuotas = Object.entries(data.quotas)