fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
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:
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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(/ /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">
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user