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

@@ -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} />