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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user