perf: memo GridCard, fuse save fns, fix slash tab active color
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-06-14 14:06:05 +00:00
parent a8785ed4f1
commit a623454347
120 changed files with 12301 additions and 785 deletions

View File

@@ -4,7 +4,7 @@ import { useState } from 'react'
import { updateAISettings } from '@/app/actions/ai-settings'
import { DemoModeToggle } from '@/components/demo-mode-toggle'
import { toast } from 'sonner'
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react'
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2, ImageIcon } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { motion } from 'motion/react'
import { cn } from '@/lib/utils'
@@ -23,6 +23,7 @@ interface AISettingsPanelProps {
autoLabeling: boolean
noteHistory: boolean
noteHistoryMode: 'manual' | 'auto'
svgComplexity?: 'simple' | 'illustrated' | 'rich'
}
}
@@ -73,6 +74,20 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
}
}
const handleSvgComplexityChange = async (value: 'simple' | 'illustrated' | 'rich') => {
setSettings(prev => ({ ...prev, svgComplexity: value }))
try {
setIsPending(true)
await updateAISettings({ svgComplexity: value })
toast.success(t('aiSettings.saved'))
} catch {
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleDemoModeToggle = async (enabled: boolean) => {
setSettings(prev => ({ ...prev, demoMode: enabled }))
try {
@@ -255,6 +270,117 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
)}
</div>
{/* SVG Complexity selector */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-paper dark:bg-brand-accent/10 rounded-2xl border border-brand-accent/20 text-brand-accent">
<ImageIcon size={18} />
</div>
<div className="space-y-1">
<h4 className="text-[13px] font-bold text-ink">{t('aiSettings.svgComplexity')}</h4>
<p className="text-[10px] text-concrete leading-relaxed">{t('aiSettings.svgComplexityDesc')}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{([
{
value: 'simple' as const,
label: t('aiSettings.svgComplexitySimple'),
desc: t('aiSettings.svgComplexitySimpleDesc'),
preview: (
<svg viewBox="0 0 80 60" className="w-full h-full">
<rect width="80" height="60" fill="#F5F0E8" rx="4"/>
<circle cx="40" cy="28" r="14" fill="none" stroke="#C4A882" strokeWidth="2.5"/>
<line x1="40" y1="14" x2="40" y2="42" stroke="#C4A882" strokeWidth="2"/>
<line x1="26" y1="28" x2="54" y2="28" stroke="#C4A882" strokeWidth="2"/>
<circle cx="40" cy="28" r="3" fill="#8B4513"/>
</svg>
),
},
{
value: 'illustrated' as const,
label: t('aiSettings.svgComplexityIllustrated'),
desc: t('aiSettings.svgComplexityIllustratedDesc'),
preview: (
<svg viewBox="0 0 80 60" className="w-full h-full">
<defs>
<linearGradient id="bg-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#1a1a2e"/>
<stop offset="100%" stopColor="#16213e"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="40%">
<stop offset="0%" stopColor="#5F9EA0" stopOpacity="0.6"/>
<stop offset="100%" stopColor="#5F9EA0" stopOpacity="0"/>
</radialGradient>
</defs>
<rect width="80" height="60" fill="url(#bg-grad)" rx="4"/>
<ellipse cx="40" cy="24" rx="28" ry="18" fill="url(#glow)"/>
<rect x="18" y="30" width="44" height="22" fill="#2a2a4a" rx="3"/>
<rect x="24" y="35" width="8" height="12" fill="#5F9EA0" rx="2" opacity="0.8"/>
<rect x="36" y="32" width="8" height="15" fill="#C4A882" rx="2" opacity="0.8"/>
<rect x="48" y="37" width="8" height="10" fill="#C9A9A6" rx="2" opacity="0.8"/>
<circle cx="40" cy="16" r="8" fill="none" stroke="#5F9EA0" strokeWidth="1.5" opacity="0.7"/>
<circle cx="40" cy="16" r="3" fill="#5F9EA0" opacity="0.9"/>
</svg>
),
},
{
value: 'rich' as const,
label: t('aiSettings.svgComplexityRich'),
desc: t('aiSettings.svgComplexityRichDesc'),
preview: (
<svg viewBox="0 0 80 60" className="w-full h-full">
<rect width="80" height="60" fill="#0F172A" rx="4"/>
<circle cx="40" cy="30" r="8" fill="#5F9EA0" opacity="0.9"/>
<circle cx="16" cy="18" r="6" fill="#C4A882" opacity="0.8"/>
<circle cx="64" cy="18" r="6" fill="#C9A9A6" opacity="0.8"/>
<circle cx="16" cy="42" r="6" fill="#A8B5A0" opacity="0.8"/>
<circle cx="64" cy="42" r="6" fill="#8B4513" opacity="0.8"/>
<line x1="40" y1="30" x2="16" y2="18" stroke="#5F9EA0" strokeWidth="1" opacity="0.5"/>
<line x1="40" y1="30" x2="64" y2="18" stroke="#C4A882" strokeWidth="1" opacity="0.5"/>
<line x1="40" y1="30" x2="16" y2="42" stroke="#A8B5A0" strokeWidth="1" opacity="0.5"/>
<line x1="40" y1="30" x2="64" y2="42" stroke="#C9A9A6" strokeWidth="1" opacity="0.5"/>
<text x="40" y="33" textAnchor="middle" fontSize="4" fill="white" fontFamily="sans-serif">MAIN</text>
<text x="16" y="21" textAnchor="middle" fontSize="3.5" fill="white" fontFamily="sans-serif">idea 1</text>
<text x="64" y="21" textAnchor="middle" fontSize="3.5" fill="white" fontFamily="sans-serif">idea 2</text>
</svg>
),
},
] as const).map((opt) => {
const isActive = (settings.svgComplexity ?? 'simple') === opt.value
return (
<button
key={opt.value}
type="button"
onClick={() => handleSvgComplexityChange(opt.value)}
className={cn(
'relative flex flex-col rounded-2xl border-2 overflow-hidden transition-all duration-200 text-left cursor-pointer',
isActive
? 'border-brand-accent shadow-md shadow-brand-accent/10'
: 'border-border hover:border-brand-accent/40 hover:shadow-sm',
)}
>
<div className="aspect-[4/3] w-full bg-muted/30">
{opt.preview}
</div>
<div className={cn(
'p-3 space-y-0.5 transition-colors',
isActive ? 'bg-brand-accent/5' : 'bg-transparent',
)}>
<div className="flex items-center justify-between">
<span className="text-[11px] font-bold text-ink">{opt.label}</span>
{isActive && (
<div className="w-2.5 h-2.5 rounded-full bg-brand-accent"/>
)}
</div>
<p className="text-[9px] text-concrete leading-relaxed">{opt.desc}</p>
</div>
</button>
)
})}
</div>
</div>
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
</div>
</motion.div>

View File

@@ -2,11 +2,12 @@
import { useCallback, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { KeyRound, Loader2, Shield, Trash2 } from 'lucide-react'
import { KeyRound, Loader2, Shield, Trash2, CheckCircle2, XCircle, Zap, FlaskConical, Pencil, X } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
@@ -16,154 +17,359 @@ import {
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
type PublicKey = {
// ─── Provider display info ────────────────────────────────────────────────────
const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' },
anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' },
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' },
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
}
function displayName(provider: string): string {
return PROVIDER_INFO[provider]?.name ?? provider
}
const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
const NEEDS_BASE_URL = new Set(['custom', 'custom_openai', 'custom_anthropic'])
function getDefaultBaseUrl(p: string): string {
if (p === 'custom_openai') return 'https://api.openai.com/v1'
if (p === 'custom_anthropic') return 'https://api.anthropic.com/v1'
return ''
}
// ─── Types ────────────────────────────────────────────────────────────────────
type SavedKey = {
provider: string
alias: string
alias: string | null
model: string | null
baseUrl: string | null
isActive: boolean
lastUsedAt: string | null
}
async function fetchByokKeys(): Promise<{
keys: PublicKey[]
allowedProviders: string[]
providerModels: Record<string, string[]>
}> {
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
const res = await fetch('/api/user/api-keys')
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.message || body.error || 'Failed to load keys')
}
if (!res.ok) throw new Error('Erreur de chargement')
return res.json()
}
function providerLabel(t: (key: string) => string, provider: string): string {
const key = `byokSettings.providers.${provider}`
const translated = t(key)
return translated === key ? provider : translated
async function fetchModelsFromServer(provider: string, key?: string, baseUrl?: string): Promise<string[]> {
const q = new URLSearchParams({ provider })
if (key) q.set('key', key)
if (baseUrl) q.set('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/live-models?${q}`)
if (!res.ok) return []
const body = await res.json()
return body.success && Array.isArray(body.models) ? body.models : []
}
// ─── Inline edit form for a saved key ────────────────────────────────────────
function EditKeyForm({
savedKey,
onDone,
onInvalidate,
}: {
savedKey: SavedKey
onDone: () => void
onInvalidate: () => void
}) {
const [alias, setAlias] = useState(savedKey.alias ?? '')
const [model, setModel] = useState(savedKey.model ?? '')
const [baseUrl, setBaseUrl] = useState(savedKey.baseUrl ?? getDefaultBaseUrl(savedKey.provider))
const [newKey, setNewKey] = useState('') // empty = keep existing key
const [models, setModels] = useState<string[]>([])
const [loadingModels, setLoadingModels] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
// Load models on mount
useState(() => {
if (manualModel) return
fetchModelsFromServer(savedKey.provider, undefined, baseUrl || undefined).then((list) => {
setModels(list)
if (list.length > 0 && !model) setModel(list[0])
})
})
const saveMutation = useMutation({
mutationFn: async () => {
const payload: Record<string, string | undefined> = {
model: model || undefined,
alias: alias.trim() || undefined,
}
if (needsUrl) payload.baseUrl = baseUrl
if (newKey.length >= 8) payload.apiKey = newKey
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(savedKey.provider)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
},
onSuccess: () => {
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
onInvalidate()
onDone()
},
onError: (err: Error) => toast.error(err.message),
})
async function testModel() {
const keyToUse = newKey.length >= 8 ? newKey : ''
if (!keyToUse && !model) return
if (!keyToUse) {
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
return
}
setTesting(true)
setTestResult(null)
try {
const q = new URLSearchParams({ provider: savedKey.provider, key: keyToUse, model })
if (needsUrl && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch {
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
} finally {
setTesting(false)
}
}
const showModelDropdown = !manualModel && models.length > 0
const showModelInput = manualModel || (!loadingModels && models.length === 0)
return (
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
Modifier {displayName(savedKey.provider)}
</p>
{/* Alias */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
</div>
{needsUrl && (
<div className="space-y-1">
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div>
)}
</div>
{/* Model */}
<div className="space-y-1">
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
{showModelDropdown ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />Chargement...</div>}
</div>
{/* Key rotation (optional) */}
<div className="space-y-1">
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
Nouvelle clé API <span className="normal-case font-normal text-concrete">(laisser vide pour conserver l'actuelle)</span>
</Label>
<Input
id={`edit-key-${savedKey.provider}`}
type="password"
autoComplete="off"
value={newKey}
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
placeholder="sk-... (optionnel)"
/>
</div>
{/* Test result */}
{testResult && (
<div className={cn(
'flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] border',
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400'
)}>
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
<span>
{testResult.ok
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? 'Échec'
}
</span>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={!newKey || newKey.length < 8 || testing}
onClick={testModel}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
Tester
</button>
<Button
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
>
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Enregistrer les modifications'}
</Button>
<button
type="button"
onClick={onDone}
className="h-9 w-9 rounded-lg flex items-center justify-center border border-border text-concrete hover:text-ink hover:bg-muted transition-colors"
>
<X size={13} />
</button>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function ByokSettingsPanel() {
const { t } = useLanguage()
const queryClient = useQueryClient()
const [provider, setProvider] = useState('')
const [apiKey, setApiKey] = useState('')
const [alias, setAlias] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [model, setModel] = useState('')
const [customModel, setCustomModel] = useState('')
const [isCustomModel, setIsCustomModel] = useState(false)
const [models, setModels] = useState<string[]>([])
const [loadingModels, setLoadingModels] = useState(false)
const [keyOk, setKeyOk] = useState(false)
const [verifying, setVerifying] = useState(false)
const [editingProvider, setEditingProvider] = useState<string | null>(null)
// Dynamic models fetched directly via user's API Key
const [liveModels, setLiveModels] = useState<string[]>([])
const [isFetchingLiveModels, setIsFetchingLiveModels] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const { data, isLoading, error } = useQuery({
queryKey: ['user', 'api-keys'],
queryFn: fetchByokKeys,
queryFn: loadKeys,
})
const providerModels = data?.providerModels ?? {}
const handleProviderChange = (p: string) => {
setProvider(p)
setApiKey('')
setLiveModels([])
setIsCustomModel(false)
setModel('')
setCustomModel('')
}
// Triggered dynamically to fetch models when user enters/pastes their API key
const fetchLiveModels = async (p: string, key: string) => {
if (!p || !key || key.length < 8) return;
setIsFetchingLiveModels(true)
try {
const query = new URLSearchParams({ provider: p, key })
const res = await fetch(`/api/user/api-keys/live-models?${query.toString()}`)
if (res.ok) {
const body = await res.json()
if (body.success && Array.isArray(body.models)) {
setLiveModels(body.models)
if (body.models.length > 0) {
setModel(body.models[0])
setIsCustomModel(false)
} else {
setIsCustomModel(true)
}
return;
}
}
} catch (err) {
console.error('[fetchLiveModels] Failed:', err)
} finally {
setIsFetchingLiveModels(false)
}
// Fallback if request fails
const fallbackList = providerModels[p] || []
setLiveModels(fallbackList)
if (fallbackList.length > 0) {
setModel(fallbackList[0])
setIsCustomModel(false)
} else {
setIsCustomModel(true)
}
}
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['user', 'api-keys'] })
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
}, [queryClient])
async function onProviderChange(p: string) {
setProvider(p)
setApiKey('')
setBaseUrl(getDefaultBaseUrl(p))
setKeyOk(false)
setTestResult(null)
setModel('')
setModels([])
if (MANUAL_MODEL_PROVIDERS.has(p)) return
setLoadingModels(true)
try {
const list = await fetchModelsFromServer(p)
setModels(list)
if (list.length > 0) setModel(list[0])
} finally { setLoadingModels(false) }
}
async function refreshModels(p: string, key: string, _baseUrl?: string) {
if (!p || key.length < 8 || MANUAL_MODEL_PROVIDERS.has(p)) return
const effectiveBaseUrl = _baseUrl ?? (NEEDS_BASE_URL.has(p) ? baseUrl : undefined)
setLoadingModels(true)
try {
const list = await fetchModelsFromServer(p, key, effectiveBaseUrl)
if (list.length > 0) { setModels(list); if (!model) setModel(list[0]) }
} finally { setLoadingModels(false) }
}
async function verifyKey() {
if (!provider || apiKey.length < 8) return
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return }
setVerifying(true)
setTestResult(null)
try {
const q = new URLSearchParams({ provider, key: apiKey })
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/verify?${q}`)
const body = await res.json()
if (res.ok && body.valid) {
setKeyOk(true)
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
toast.success('Clé API valide ✓')
} else {
setKeyOk(false)
toast.error(body.message ?? 'Clé API invalide')
}
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
finally { setVerifying(false) }
}
async function testModel() {
if (!provider || apiKey.length < 8 || !model) return
setTesting(true)
setTestResult(null)
try {
const q = new URLSearchParams({ provider, key: apiKey, model })
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
setTestResult(await res.json())
} catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) }
finally { setTesting(false) }
}
const saveMutation = useMutation({
mutationFn: async () => {
const resolvedModel = isCustomModel ? customModel : model
const payload: Record<string, string | undefined> = {
provider, apiKey,
model: model || undefined,
alias: alias.trim() || undefined,
}
if (NEEDS_BASE_URL.has(provider)) payload.baseUrl = baseUrl
const res = await fetch('/api/user/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
provider,
apiKey,
alias: alias || undefined,
model: resolvedModel || undefined,
}),
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(body.message || body.error || 'Save failed')
}
return body
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
},
onSuccess: () => {
toast.success(t('byokSettings.saved'))
setApiKey('')
setAlias('')
setProvider('')
setModel('')
setCustomModel('')
setLiveModels([])
setIsCustomModel(false)
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
invalidate()
},
onError: (err: Error) => {
toast.error(err.message || t('byokSettings.error'))
},
onError: (err: Error) => toast.error(err.message),
})
const toggleMutation = useMutation({
mutationFn: async ({
provider: p,
isActive,
}: {
provider: string
isActive: boolean
}) => {
mutationFn: async ({ p, isActive }: { p: string; isActive: boolean }) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }),
})
if (!res.ok) throw new Error('Update failed')
if (!res.ok) throw new Error('Erreur')
},
onSuccess: invalidate,
onError: () => toast.error(t('byokSettings.error')),
@@ -171,246 +377,238 @@ export function ByokSettingsPanel() {
const deleteMutation = useMutation({
mutationFn: async (p: string) => {
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
method: 'DELETE',
})
if (!res.ok) throw new Error('Delete failed')
},
onSuccess: () => {
toast.success(t('byokSettings.deleted'))
invalidate()
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Erreur')
},
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
onError: () => toast.error(t('byokSettings.error')),
})
const allowed = data?.allowedProviders ?? []
const keys = data?.keys ?? []
const hasActiveByok = keys.some((k) => k.isActive)
const activeKey = keys.find((k) => k.isActive)
const tierBlocked = !isLoading && allowed.length === 0
const saveDisabled = !provider || apiKey.length < 8 || saveMutation.isPending || (NEEDS_BASE_URL.has(provider) && baseUrl.length < 4)
const canTest = provider && apiKey.length >= 8 && model.length > 0
const showModelDropdown = !MANUAL_MODEL_PROVIDERS.has(provider) && models.length > 0
const showModelInput = !!provider && (MANUAL_MODEL_PROVIDERS.has(provider) || (!loadingModels && models.length === 0))
if (isLoading) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-5">
<Loader2 className="h-4 w-4 animate-spin" />
{t('byokSettings.loading')}
</div>
)
}
if (error) {
return (
<p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
)
}
if (isLoading) return <div className="flex items-center gap-2 text-sm text-muted-foreground p-5"><Loader2 className="h-4 w-4 animate-spin" />{t('byokSettings.loading')}</div>
if (error) return <p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
return (
<div
id="byok"
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6"
>
<div id="byok" className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6">
{/* Header */}
<div className="flex items-start gap-5">
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20">
<KeyRound size={18} />
</div>
<div className="space-y-1">
<h3 className="text-[13px] font-bold text-ink flex items-center gap-2 flex-wrap">
{t('byokSettings.title')}
{hasActiveByok && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-medium">
<Shield size={10} />
{t('byokSettings.badgeActive')}
</span>
)}
</h3>
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20"><KeyRound size={18} /></div>
<div className="flex-1 space-y-1">
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3>
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
</div>
</div>
{tierBlocked ? (
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">
{t('byokSettings.tierRequired')}
</p>
) : (
<div className="space-y-4 border border-border/60 rounded-2xl p-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="byok-provider" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Select
value={provider}
onValueChange={handleProviderChange}
disabled={saveMutation.isPending}
>
<SelectTrigger id="byok-provider">
<SelectValue placeholder={t('byokSettings.providerPlaceholder')} />
</SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
{providerLabel(t, p)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="byok-alias" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.alias')}</Label>
<Input
id="byok-alias"
value={alias}
onChange={(e) => setAlias(e.target.value)}
placeholder={t('byokSettings.aliasPlaceholder')}
disabled={saveMutation.isPending}
/>
</div>
</div>
{/* Model Selection Row */}
{provider && (
<div className="grid gap-4 sm:grid-cols-2 pt-2 border-t border-border/40">
<div className="space-y-2">
<Label htmlFor="byok-model-select" className="text-[10px] font-bold uppercase tracking-widest text-concrete">
Modèle de l'IA (Optionnel)
</Label>
{isFetchingLiveModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2">
<Loader2 className="h-3 w-3 animate-spin text-brand-accent" />
Récupération de vos modèles disponibles...
</div>
) : liveModels && liveModels.length > 0 ? (
<Select
value={isCustomModel ? 'custom' : model}
onValueChange={(val) => {
if (val === 'custom') {
setIsCustomModel(true)
} else {
setIsCustomModel(false)
setModel(val)
}
}}
disabled={saveMutation.isPending}
>
<SelectTrigger id="byok-model-select">
<SelectValue placeholder="Choisir un modèle..." />
</SelectTrigger>
<SelectContent>
{liveModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
<SelectItem value="custom">Autre / Modèle personnalisé...</SelectItem>
</SelectContent>
</Select>
) : (
<div className="text-xs text-concrete py-2 italic">
Entrez votre clé API ci-dessous pour charger vos modèles.
</div>
)}
</div>
{(isCustomModel || (!isFetchingLiveModels && !(liveModels && liveModels.length > 0))) && (
<div className="space-y-2">
<Label htmlFor="byok-model-custom" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Saisir le nom du modèle</Label>
<Input
id="byok-model-custom"
value={isCustomModel ? customModel : model}
onChange={(e) => {
if (isCustomModel) {
setCustomModel(e.target.value)
} else {
setModel(e.target.value)
}
}}
placeholder="ex. deepseek-reasoner, minimax-abab6.5"
disabled={saveMutation.isPending}
/>
</div>
)}
</div>
{/* BYOK Status */}
{!tierBlocked && (
<div className={cn(
'flex items-center gap-3 rounded-xl px-4 py-3 text-[11px] font-medium border',
activeKey
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20'
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
)}>
{activeKey ? (
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
) : (
<><Shield size={14} className="shrink-0" /><span>Aucune clé active utilisation des quotas Momento</span></>
)}
<div className="space-y-2">
<Label htmlFor="byok-key" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.apiKey')}</Label>
<Input
id="byok-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => {
const val = e.target.value
setApiKey(val)
fetchLiveModels(provider, val)
}}
placeholder={t('byokSettings.apiKeyPlaceholder')}
disabled={saveMutation.isPending}
/>
</div>
<button
type="button"
disabled={!provider || apiKey.length < 8 || saveMutation.isPending}
onClick={() => saveMutation.mutate()}
className={cn(
'px-6 py-3 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300',
'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
'disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none'
)}
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{t('byokSettings.save')}
</div>
</button>
</div>
)}
{keys.length > 0 && (
<ul className="space-y-3">
{keys.map((key) => (
<li
key={key.provider}
className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-4 py-3"
>
<div className="min-w-0">
<div className="text-[13px] font-bold text-ink">{providerLabel(t, key.provider)}</div>
<div className="flex flex-col gap-0.5 mt-0.5">
{key.alias ? (
<p className="text-[10px] text-concrete truncate">{key.alias}</p>
) : null}
{key.model ? (
<p className="text-[9px] text-brand-accent font-mono truncate">Modèle : {key.model}</p>
) : null}
</div>
{tierBlocked ? (
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">{t('byokSettings.tierRequired')}</p>
) : (
<>
{/* Saved keys */}
{keys.length > 0 && (
<div className="space-y-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">Clés enregistrées</p>
<ul className="space-y-1">
{keys.map((key) => (
<li key={key.provider}>
<div className={cn(
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3 transition-colors',
key.isActive ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border/60',
editingProvider === key.provider && 'rounded-b-none border-b-0'
)}>
<div className="min-w-0 flex items-center gap-3">
<div className={cn('h-2 w-2 rounded-full shrink-0', key.isActive ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-white/20')} />
<div>
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
{key.isActive ? '● Actif' : '○ Inactif'}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* Edit button */}
<button
type="button"
className={cn(
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
editingProvider === key.provider
? 'text-violet-600 bg-violet-500/10 border border-violet-500/30'
: 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
)}
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
>
<Pencil size={12} />
</button>
{/* Toggle */}
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox" className="sr-only peer" checked={key.isActive}
onChange={(e) => toggleMutation.mutate({ p: key.provider, isActive: e.target.checked })}
disabled={toggleMutation.isPending}
/>
<div className="w-9 h-5 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[16px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[3px] after:left-[3px] after:bg-white after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500" />
</label>
{/* Delete */}
<button
type="button"
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
disabled={deleteMutation.isPending}
onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }}
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* Inline edit form */}
{editingProvider === key.provider && (
<div className="border border-violet-500/20 border-t-0 rounded-b-xl overflow-hidden">
<EditKeyForm
savedKey={key}
onDone={() => setEditingProvider(null)}
onInvalidate={invalidate}
/>
</div>
)}
</li>
))}
</ul>
</div>
)}
{/* Add key form */}
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Fournisseur</Label>
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
<span className="font-medium">{displayName(p)}</span>
{PROVIDER_INFO[p]?.hint && <span className="ml-2 text-[10px] text-concrete">{PROVIDER_INFO[p].hint}</span>}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3 shrink-0">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={key.isActive}
onChange={(e) => toggleMutation.mutate({ provider: key.provider, isActive: e.target.checked })}
disabled={toggleMutation.isPending}
<div className="space-y-1.5">
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Alias <span className="normal-case font-normal">(optionnel)</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
</div>
</div>
{NEEDS_BASE_URL.has(provider) && (
<div className="space-y-1.5">
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Clé API</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="byok-key" type="password" autoComplete="off" value={apiKey}
onChange={(e) => { const val = e.target.value; setApiKey(val); setKeyOk(false); setTestResult(null); refreshModels(provider, val, baseUrl) }}
placeholder="sk-..." disabled={saveMutation.isPending}
className={cn(keyOk && 'border-emerald-500/60')}
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
</label>
{keyOk && <CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-500 pointer-events-none" />}
</div>
<button
type="button"
className="h-8 w-8 rounded-lg flex items-center justify-center text-destructive/60 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
disabled={deleteMutation.isPending}
onClick={() => {
if (confirm(t('byokSettings.confirmDelete'))) {
deleteMutation.mutate(key.provider)
}
}}
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey}
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
>
<Trash2 size={14} />
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
</button>
</div>
</li>
))}
</ul>
)}
</div>
{!tierBlocked && keys.length === 0 && (
<p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>
{provider && (
<div className="space-y-1.5">
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
{loadingModels ? (
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />Récupération des modèles...</div>
) : showModelDropdown ? (
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-model"><SelectValue placeholder="Choisir un modèle..." /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
<Input id="byok-model" value={model} onChange={(e) => { setModel(e.target.value); setTestResult(null) }} placeholder="ex. gpt-4o-mini, MiniMax-M2.7" disabled={saveMutation.isPending} />
) : null}
</div>
)}
{testResult && (
<div className={cn('flex items-start gap-3 rounded-xl px-4 py-3 text-[11px] border',
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400')}>
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
<div className="space-y-0.5">
{testResult.ok ? (
<><p className="font-semibold">Modèle opérationnel ✓{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">Réponse : {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? 'Échec du test'}</p>}
</div>
</div>
)}
<div className="flex gap-2">
<button
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}Tester
</button>
<Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
className="flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] transition-all duration-200 bg-ink text-paper shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
</div>
</Button>
</div>
</div>
{keys.length === 0 && <p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>}
</>
)}
</div>
)

View File

@@ -13,7 +13,7 @@ import {
Trash2, Copy, Repeat, Link, ChevronRight,
Heading1, Heading2, Heading3, List, ListOrdered,
CheckSquare, Quote, CodeXml, Database,
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy,
ArrowUp, ArrowDown, AlignLeft, ClipboardCopy, Sparkles,
} from 'lucide-react'
import { replaceBlockWithStructuredView } from '@/components/tiptap-structured-view-block-extension'
@@ -80,7 +80,7 @@ function getBlockPlainContent(editor: Editor, blockPos: number, blockNode: PMNod
const from = blockPos + 1
const to = blockPos + node.nodeSize - 1
if (to > from) {
return editor.state.doc.textBetween(from, to, '\n', '\0').trim()
return editor.state.doc.textBetween(from, to, '\n', '').trim()
}
return node.textContent?.trim() ?? ''
}
@@ -208,6 +208,63 @@ export function BlockActionMenu({
onClose()
}, [blockNode, blockPos, editor, noteId, onBlockReferenceCopied, onClose, sourceNoteTitle, t])
const handleCreateDiagram = useCallback(async () => {
const text = getBlockPlainContent(editor, blockPos, blockNode)
if (!text || text.trim().length < 5) {
toast.error(t('blockAction.createDiagramEmpty') || "Le texte est trop court pour générer un diagramme.")
onClose()
return
}
onClose()
const toastId = toast.loading(t('blockAction.createDiagramLoading') || "Génération du diagramme Excalidraw par l'IA...")
try {
const { generateDiagramFromText } = await import('@/app/actions/diagram')
const res = await generateDiagramFromText(text)
if (!res.success || !res.canvasId) {
throw new Error(res.error || "La génération du diagramme a échoué.")
}
const canvasId = res.canvasId
const canvasRes = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
const canvasData = await canvasRes.json()
if (!canvasRes.ok || !canvasData.canvas?.data) {
throw new Error("Impossible de charger les données du diagramme généré.")
}
const { exportExcalidrawSceneToPngBlob } = await import('@/lib/client/excalidraw-export-image')
const blob = await exportExcalidrawSceneToPngBlob(canvasData.canvas.data)
if (!blob) {
throw new Error("Échec du rendu du diagramme en image PNG.")
}
const fd = new FormData()
fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`)
const up = await fetch('/api/upload', { method: 'POST', body: fd })
const upJson = await up.json()
if (!up.ok || !upJson.url) {
throw new Error("Échec du téléversement du diagramme généré.")
}
const imageUrl = upJson.url
const insertPos = blockPos + (blockNode ? blockNode.nodeSize : 0)
const htmlToInsert = `<p><a href="/lab?id=${canvasId}" target="_blank"><img src="${imageUrl}" alt="Diagramme Excalidraw" style="max-width: 100%; border-radius: 8px; border: 1px solid rgba(0,0,0,0.1);" /></a></p><p>🎨 <a href="/lab?id=${canvasId}" target="_blank"><strong>${t('blockAction.createDiagramSuccess') || "Éditer le diagramme dans le Lab Excalidraw"}</strong></a></p>`.trim();
editor.chain().focus().insertContentAt(insertPos, htmlToInsert).run()
toast.success(t('blockAction.createDiagramSuccess') || "Diagramme généré et inséré avec succès !", { id: toastId })
} catch (err: any) {
console.error('[handleCreateDiagram] Error:', err)
toast.error(err.message || "Une erreur est survenue lors de la génération.", { id: toastId })
}
}, [editor, blockPos, blockNode, onClose, t])
const handleTurnInto = useCallback((option: TurnIntoOption) => {
if (blockPos >= 0 && blockNode) {
if (option.isDatabase) {
@@ -312,6 +369,14 @@ export function BlockActionMenu({
<div className="block-action-separator" />
{/* Création de diagramme */}
<button type="button" className="block-action-item" onClick={() => { void handleCreateDiagram() }}>
<Sparkles size={16} className="text-amber-500 transition-all duration-200" />
<span className="font-medium text-amber-700 dark:text-amber-400">{t('blockAction.createDiagram')}</span>
</button>
<div className="block-action-separator" />
{/* Copier */}
<button type="button" className="block-action-item" onClick={() => { void handleCopyContent() }}>
<ClipboardCopy size={16} />

View File

@@ -557,6 +557,15 @@ export function HomeClient({
emitNoteChange({ type: 'updated', note: fresh })
}, [patchNoteInList])
const handleNoteIllustrationDeleted = useCallback((noteId: string) => {
patchNoteInList(noteId, { illustrationSvg: null })
setNotes((prev) => {
const updated = prev.find((n) => n.id === noteId)
if (updated) emitNoteChange({ type: 'updated', note: { ...updated, illustrationSvg: null } })
return prev
})
}, [patchNoteInList])
const handleGridReorder = useCallback(
async (orderedIds: string[]) => {
setSortOrder('manual')
@@ -1174,6 +1183,7 @@ export function HomeClient({
onMoveToNotebook={handleMoveNoteToNotebook}
onNotePatch={handleNoteContentPatch}
onNoteIllustrationGenerated={handleNoteIllustrationGenerated}
onNoteIllustrationDeleted={handleNoteIllustrationDeleted}
onGridReorder={handleGridReorder}
/>
)}

View File

@@ -1,7 +1,7 @@
'use client'
import { useLanguage } from '@/lib/i18n'
import { acceptEssentialsOnly, acceptAllOptional } from '@/lib/consent/cookie-consent'
import { saveConsentWithSync } from '@/lib/consent/cookie-consent'
interface CookieConsentBannerProps {
onManage: () => void
@@ -29,14 +29,14 @@ export function CookieConsentBanner({ onManage }: CookieConsentBannerProps) {
<div className="flex flex-wrap items-center gap-2 shrink-0">
<button
type="button"
onClick={() => acceptEssentialsOnly()}
onClick={() => saveConsentWithSync(false, false)}
className="px-4 py-2.5 border border-border rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] text-ink hover:bg-white/60 dark:hover:bg-white/5 transition-colors"
>
{t('consent.banner.acceptEssentials')}
</button>
<button
type="button"
onClick={() => acceptEssentialsOnly()}
onClick={() => saveConsentWithSync(false, false)}
className="px-4 py-2.5 text-[10px] font-bold uppercase tracking-[0.15em] text-concrete hover:text-ink transition-colors"
>
{t('consent.banner.rejectNonEssential')}
@@ -50,7 +50,7 @@ export function CookieConsentBanner({ onManage }: CookieConsentBannerProps) {
</button>
<button
type="button"
onClick={() => acceptAllOptional()}
onClick={() => saveConsentWithSync(true, false)}
className="px-5 py-2.5 bg-foreground text-background rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 transition-opacity"
>
{t('consent.banner.acceptAll')}

View File

@@ -10,7 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { getConsent, setConsent } from '@/lib/consent/cookie-consent'
import { getConsent, saveConsentWithSync } from '@/lib/consent/cookie-consent'
import { toast } from 'sonner'
interface CookiePreferencesDialogProps {
@@ -29,7 +29,7 @@ export function CookiePreferencesDialog({ open, onOpenChange }: CookiePreference
}, [open])
const handleSave = () => {
setConsent({ analytics, marketing: false })
saveConsentWithSync(analytics, false)
toast.success(t('consent.preferences.saved'))
onOpenChange(false)
}

View File

@@ -20,6 +20,21 @@ interface MarkdownContentProps {
}
export const MarkdownContent = memo(function MarkdownContent({ content, className }: MarkdownContentProps) {
// Strip <think>...</think> and <thinking>...</thinking> blocks produced by reasoning models
// (MiniMax, DeepSeek R1, etc.) before passing to ReactMarkdown — rehypeRaw would otherwise
// try to render them as HTML elements causing React warnings.
// Also handles partial/unclosed blocks during streaming (e.g. <think> without </think> yet).
const safeContent = useMemo(
() => content
.replace(/<think>[\s\S]*?<\/think>/gi, '') // complete think blocks
.replace(/<think>[\s\S]*/i, '') // unclosed think block (still streaming)
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '') // complete thinking blocks
.replace(/<thinking>[\s\S]*/i, '') // unclosed thinking block
.trim(),
[content]
)
return (
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown
@@ -56,8 +71,9 @@ export const MarkdownContent = memo(function MarkdownContent({ content, classNam
},
}}
>
{content}
{safeContent}
</ReactMarkdown>
</div>
)
})

View File

@@ -13,7 +13,7 @@ import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings'
import { getAISettings, updateAISettings } from '@/app/actions/ai-settings'
import { extractImagesFromHTML } from '@/lib/utils'
import { queryKeys } from '@/lib/query-keys'
import type { RichTextEditorHandle } from '@/components/rich-text-editor'
@@ -40,12 +40,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true)
useEffect(() => {
if (session?.user?.id) {
getAISettings(session.user.id).then(settings => {
setAiAssistantEnabled(settings.paragraphRefactor !== false)
setAutoLabelingEnabled(settings.autoLabeling !== false)
setAutoSaveEnabled(settings.autoSave !== false)
}).catch(err => console.error("Failed to fetch AI settings", err))
}
}, [session?.user?.id])
@@ -64,6 +66,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setContentState(newVal)
}, [])
// setContent : met à jour contentRef immédiatement (pour les saves),
// mais ne déclenche un re-render React qu'après 800ms (throttle) pour éviter
// un render à chaque frappe.
const setContent = useCallback((newVal: string) => {
contentRef.current = newVal
if (debounceTimeoutRef.current) {
@@ -71,7 +76,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
debounceTimeoutRef.current = setTimeout(() => {
setContentState(newVal)
}, 1000)
}, 800)
}, [])
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [labels, setLabels] = useState<string[]>(note.labels || [])
@@ -364,8 +369,10 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleGenerateTitles = async () => {
// Utiliser le contenu live de l'éditeur (TipTap) plutôt que le state debounced
const liveContent = resolveContentForSave()
const fullContentForAI = [
content,
liveContent,
...links.map(l => `${l.title || ''} ${l.description || ''}`)
]
.join(' ')
@@ -386,7 +393,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const response = await fetch('/api/ai/title-suggestions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: fullContentForAI }),
body: JSON.stringify({ content: fullContentForAI.substring(0, 8000) }),
})
if (!response.ok) {
@@ -658,7 +665,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}
const handleSave = async () => {
// Fonction de sauvegarde unifiée — évite la duplication handleSave/handleSaveInPlace
const performSave = useCallback(async ({ silent = false, inPlace = false }: { silent?: boolean; inPlace?: boolean } = {}) => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
@@ -677,7 +685,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
size,
}, { skipRevalidation: true })
if (contentToSave !== content) setContentImmediate(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
if (imagesToSave.length !== images.length || imagesToSave.some((u, i) => u !== images[i])) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
const deletedImages = Array.from(new Set([
...removedImageUrls,
@@ -694,14 +702,19 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
setLastSavedAt(new Date())
toast.success(t('notes.saved') || 'Note sauvegardée !')
if (!silent) toast.success(t('notes.saved') || 'Note sauvegardée !')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.')
} finally {
setIsSaving(false)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id, note.notebookId, title, labels, images, links, color, currentReminder, isMarkdown, size, removedImageUrls, content, t])
// Aliases stables pour la compatibilité
const handleSave = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: false }), [performSave])
const handleSaveInPlace = useCallback((opts?: { silent?: boolean }) => performSave({ ...opts, inPlace: true }), [performSave])
const handleCheckItem = (id: string) => {
setCheckItems(items =>
@@ -783,73 +796,47 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}
const handleSaveInPlace = async () => {
setIsSaving(true)
try {
const contentToSave = resolveContentForSave()
const imagesToSave = resolveImagesForSave(contentToSave)
const updatePayload = {
title: title.trim() || null,
content: contentToSave,
checkItems: null,
labels,
images: imagesToSave,
links,
color,
reminder: currentReminder,
isMarkdown,
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
}
const result = await updateNote(note.id, updatePayload, { skipRevalidation: true })
if (contentToSave !== content) setContentImmediate(contentToSave)
if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave)
prevNoteRef.current = { ...prevNoteRef.current, ...result }
const deletedImages = Array.from(new Set([
...removedImageUrls,
...images.filter(url => !imagesToSave.includes(url))
]))
if (deletedImages.length > 0) {
cleanupOrphanedImages(deletedImages, note.id).catch(() => {})
setRemovedImageUrls([])
}
await refreshLabels()
onNoteSaved?.(result)
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
emitNoteChange({ type: 'updated', note: result })
setIsDirty(false)
setLastSavedAt(new Date())
toast.success(t('notes.saved') || 'Saved')
} catch (error) {
console.error('[SAVE] updateNote failed:', error)
toast.error(t('notes.saveFailed') || 'Save failed')
} finally {
setIsSaving(false)
}
}
// handleSaveInPlace était un alias de handleSave — supprimé, maintenant unifié dans performSave
const handleSaveInPlaceRef = useRef(handleSaveInPlace)
handleSaveInPlaceRef.current = handleSaveInPlace
const handleSaveRef = useRef(handleSave)
handleSaveRef.current = handleSave
// Auto-save : 2s après le dernier changement si isDirty
const toggleAutoSave = useCallback(async () => {
const nextVal = !autoSaveEnabled
setAutoSaveEnabled(nextVal)
if (session?.user?.id) {
try {
await updateAISettings({ autoSave: nextVal })
toast.success(
nextVal
? t('settings.autoSaveEnabled') || 'Auto-enregistrement activé !'
: t('settings.autoSaveDisabled') || 'Auto-enregistrement désactivé'
)
} catch (err) {
console.error("Failed to update autoSave setting:", err)
toast.error(t('general.error'))
}
}
}, [autoSaveEnabled, session?.user?.id, t])
// Auto-save : 8s après le dernier changement si isDirty et autoSave est activé (silencieux — pas de toast)
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!isDirty || isSaving || readOnly) return
if (!autoSaveEnabled || !isDirty || isSaving || readOnly) return
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
autoSaveTimerRef.current = setTimeout(() => {
if (fullPage) {
void handleSaveInPlaceRef.current()
void handleSaveInPlaceRef.current({ silent: true })
} else {
void handleSaveRef.current()
void handleSaveRef.current({ silent: true })
}
}, 2000)
}, 8000)
return () => {
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
}
}, [isDirty, isSaving, readOnly, fullPage])
}, [isDirty, isSaving, readOnly, fullPage, autoSaveEnabled])
useEffect(() => {
if (!fullPage) return
@@ -914,13 +901,14 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
allImages,
colorClasses,
quotaExceededFeature,
autoSaveEnabled,
}), [
title, content, checkItems, labels, images, links, newLabel, color, size,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, lastSavedAt, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses, quotaExceededFeature, autoSaveEnabled
])
const actions: NoteEditorActions = useMemo(() => ({
@@ -975,27 +963,34 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setIsAnalyzingSuggestions: (_a) => { /* handled by useAutoTagging */ },
setPreviousContentForCopilot,
setQuotaExceededFeature,
toggleAutoSave,
}), [
handleCheckItem, handleUpdateCheckItem, handleAddCheckItem, handleRemoveCheckItem,
handleSelectGhostTag, handleDismissGhostTag, handleRemoveLabel, handleImageUpload,
handleRemoveImage, handleAddLink, handleRemoveLink, handleReminderSave,
handleRemoveReminder, handleGenerateTitles, handleSelectTitle, handleReformulate,
handleApplyRefactor, handleClarifyDirect, handleShortenDirect, handleImproveDirect,
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature
handleTransformMarkdown, handleSave, handleSaveInPlace, handleMakeCopy, setQuotaExceededFeature, toggleAutoSave
])
// Notebooks mappés en dehors du useMemo pour éviter de recréer le tableau à chaque render du contexte
const mappedNotebooks = useMemo(
() => notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt })),
[notebooks]
)
const value: NoteEditorContextValue = useMemo(() => ({
note,
readOnly,
fullPage,
state,
actions,
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name, parentId: nb.parentId, trashedAt: nb.trashedAt })),
notebooks: mappedNotebooks,
globalLabels,
fileInputRef,
textareaRef,
richTextEditorRef,
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
}), [note, readOnly, fullPage, state, actions, mappedNotebooks, globalLabels])
return (
<NoteEditorContext.Provider value={value}>

View File

@@ -297,7 +297,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<button
title={state.isDirty ? t('notes.saveNow') : t('notes.noModification')}
aria-label={state.isDirty ? t('notes.saveNoteAria') : t('notes.noChangesToSaveAria')}
onClick={actions.handleSaveInPlace}
onClick={() => actions.handleSaveInPlace()}
disabled={state.isSaving || !state.isDirty}
className={cn(
'p-1.5 rounded-full border transition-all duration-300',
@@ -339,6 +339,21 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
{t('richTextEditor.importMarkdown')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={actions.toggleAutoSave} className="flex items-center justify-between cursor-pointer">
<span className="flex items-center">
<Save className="h-4 w-4 me-2 text-muted-foreground" />
{t('settings.autoSave') || 'Auto-enregistrement'}
</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded font-bold uppercase tracking-wider',
state.autoSaveEnabled
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400'
)}>
{state.autoSaveEnabled ? (t('common.on') || 'Actif') : (t('common.off') || 'Désactivé')}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={async () => {
try {
@@ -524,7 +539,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
<Button variant="ghost" onClick={onClose}>
{t('general.cancel')}
</Button>
<Button onClick={actions.handleSave} disabled={state.isSaving}>
<Button onClick={() => actions.handleSave()} disabled={state.isSaving}>
{state.isSaving ? t('notes.saving') : t('general.save')}
</Button>
</>

View File

@@ -20,6 +20,7 @@ export interface NoteEditorState {
isSaving: boolean
isDirty: boolean
lastSavedAt: Date | null
autoSaveEnabled: boolean
isProcessingAI: boolean
aiOpen: boolean
@@ -105,8 +106,8 @@ export interface NoteEditorActions {
handleImproveDirect: () => Promise<void>
handleTransformMarkdown: () => Promise<void>
handleSave: () => Promise<void>
handleSaveInPlace: () => Promise<void>
handleSave: (opts?: { silent?: boolean }) => Promise<void>
handleSaveInPlace: (opts?: { silent?: boolean }) => Promise<void>
handleMakeCopy: () => Promise<void>
setComparisonNotes: (notes: Array<Partial<Note>>) => void
@@ -122,6 +123,7 @@ export interface NoteEditorActions {
setIsAnalyzingSuggestions: (analyzing: boolean) => void
setPreviousContentForCopilot: (content: string | null) => void
setQuotaExceededFeature: (feature: string | null) => void
toggleAutoSave: () => void
}
export interface NoteEditorContextValue {

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState, useEffect, useCallback } from 'react'
import { useMemo, useState, useEffect, useCallback, memo } from 'react'
import {
DndContext,
DragOverlay,
@@ -103,27 +103,46 @@ function NoteLabelsRow({
function NoteGridIllustrationButton({
busy,
onClick,
onDelete,
hasIllustration,
className,
}: {
busy: boolean
onClick: (e: React.MouseEvent) => void
onDelete?: (e: React.MouseEvent) => void
hasIllustration?: boolean
className?: string
}) {
const { t } = useLanguage()
return (
<button
type="button"
aria-label={t('notes.generateIllustration') || 'Générer une illustration IA'}
title={t('notes.generateIllustration') || 'Générer une illustration IA'}
className={cn(
'absolute bottom-2 end-2 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent z-10',
className,
<div className={cn('absolute bottom-2 end-2 flex items-center gap-1.5 z-10', className)}>
{hasIllustration && onDelete && (
<button
type="button"
aria-label={t('notes.deleteIllustration') || 'Supprimer l\'illustration'}
title={t('notes.deleteIllustration') || 'Supprimer l\'illustration'}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 shadow-card-rest backdrop-blur-sm transition-colors"
onClick={onDelete}
disabled={busy}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
onClick={onClick}
disabled={busy}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4 text-primary" />}
</button>
<button
type="button"
aria-label={hasIllustration
? (t('notes.regenerateIllustration') || 'Regénérer l\'illustration')
: (t('notes.generateIllustration') || 'Générer une illustration IA')}
title={hasIllustration
? (t('notes.regenerateIllustration') || 'Regénérer')
: (t('notes.generateIllustration') || 'Générer une illustration IA')}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background/95 text-foreground shadow-card-rest backdrop-blur-sm transition-colors hover:bg-accent"
onClick={onClick}
disabled={busy}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5 text-primary" />}
</button>
</div>
)
}
@@ -131,10 +150,12 @@ function NoteGridThumbnail({
note,
aiIllustrationEnabled,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
}: {
note: Note
aiIllustrationEnabled?: boolean
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
}) {
const { t } = useLanguage()
const [busy, setBusy] = useState(false)
@@ -142,7 +163,7 @@ function NoteGridThumbnail({
const handleGenerateSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (!aiIllustrationEnabled || busy || img) return
if (!aiIllustrationEnabled || busy) return
setBusy(true)
try {
const res = await generateNoteIllustrationSvg(note.id, { skipRevalidation: true })
@@ -157,7 +178,22 @@ function NoteGridThumbnail({
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100'
const handleDeleteSvg = async (e: React.MouseEvent) => {
e.stopPropagation()
if (busy) return
setBusy(true)
try {
const { updateNote } = await import('@/app/actions/notes')
await updateNote(note.id, { illustrationSvg: null })
await onNoteIllustrationDeleted?.(note.id)
} catch {
toast.error('Erreur lors de la suppression')
} finally {
setBusy(false)
}
}
const aiButtonClass = 'opacity-0 group-hover/card:opacity-100 focus-visible:opacity-100 transition-opacity duration-200'
if (img) {
return (
@@ -177,7 +213,13 @@ function NoteGridThumbnail({
aria-hidden
/>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
<NoteGridIllustrationButton
busy={busy}
onClick={handleGenerateSvg}
onDelete={handleDeleteSvg}
hasIllustration
className={aiButtonClass}
/>
)}
</>
)
@@ -188,12 +230,17 @@ function NoteGridThumbnail({
<FileText size={28} strokeWidth={1.25} />
</div>
{aiIllustrationEnabled && (
<NoteGridIllustrationButton busy={busy} onClick={handleGenerateSvg} className={aiButtonClass} />
<NoteGridIllustrationButton
busy={busy}
onClick={handleGenerateSvg}
className={aiButtonClass}
/>
)}
</>
)
}
export type { NoteCollectionActions } from '@/lib/note-change-sync'
type NotesListViewsProps = {
@@ -454,6 +501,7 @@ type GridCardSharedProps = {
onDeleteNote?: (note: Note) => void | Promise<void>
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
isOverlay?: boolean
}
@@ -623,7 +671,7 @@ function NotesGridSection({
)
}
function SortableGridCard(props: GridCardSharedProps) {
const SortableGridCard = memo(function SortableGridCard(props: GridCardSharedProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.note.id,
})
@@ -647,9 +695,9 @@ function SortableGridCard(props: GridCardSharedProps) {
<GridCard {...props} />
</div>
)
}
})
function GridCard({
const GridCard = memo(function GridCard({
note,
index,
untitled,
@@ -661,6 +709,7 @@ function GridCard({
onDeleteNote,
onMoveToNotebook,
onNoteIllustrationGenerated,
onNoteIllustrationDeleted,
isOverlay = false,
}: GridCardSharedProps) {
const router = useRouter()
@@ -699,6 +748,7 @@ function GridCard({
note={note}
aiIllustrationEnabled={aiIllustrationEnabled}
onNoteIllustrationGenerated={onNoteIllustrationGenerated}
onNoteIllustrationDeleted={onNoteIllustrationDeleted}
/>
{note.isPinned && (
<div className="absolute top-3 start-3 bg-background/90 backdrop-blur-sm p-1.5 rounded-full shadow-sm border border-border/40 text-amber-500">
@@ -771,4 +821,4 @@ function GridCard({
</div>
</motion.div>
)
}
})

View File

@@ -352,7 +352,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
if (!range) {
const { from } = ed.state.selection
const start = Math.max(0, from - 80)
const textBefore = ed.state.doc.textBetween(start, from, '\n', '\0')
const textBefore = ed.state.doc.textBetween(start, from, '\n', '')
const match = textBefore.match(/\[\[([^\]]*)$/)
if (match) {
range = { from: from - match[0].length, to: from }
@@ -531,7 +531,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
const { from, empty } = e.state.selection
if (!empty) return
const start = Math.max(0, from - 80)
const textBefore = e.state.doc.textBetween(start, from, '\n', '\0')
const textBefore = e.state.doc.textBetween(start, from, '\n', '')
const match = textBefore.match(/\[\[([^\]]*)$/)
if (match) {
noteLinkRangeRef.current = { from: from - match[0].length, to: from }

View File

@@ -22,6 +22,16 @@ interface BillingStatus {
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
hasStripeSubscription: boolean;
prices?: {
PRO: {
month: { display: string; amount: number; currency: string };
year: { display: string; amount: number; currency: string };
};
BUSINESS: {
month: { display: string; amount: number; currency: string };
year: { display: string; amount: number; currency: string };
};
};
}
const billingEnabled = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true' || process.env.NODE_ENV === 'development';
@@ -214,7 +224,8 @@ export function BillingPlans() {
{
id: 'pro',
name: t('billing.proPlan'),
price: interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€'),
price: status?.prices?.PRO?.[interval]?.display ??
(interval === 'month' ? (t('billing.proPrice') || '9,90€') : (t('billing.proAnnualPrice') || '99€')),
period: interval === 'month' ? '/mois' : '/an',
description: t('billing.proDescription') || 'Pour les consultants et créateurs exigeants.',
features: [
@@ -236,7 +247,8 @@ export function BillingPlans() {
{
id: 'business',
name: t('billing.businessPlan'),
price: interval === 'month' ? (t('billing.businessPrice') || '29,90€') : (t('billing.businessAnnualPrice') || '299€'),
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.',
features: [

View File

@@ -1457,6 +1457,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
type="button"
onClick={handleDailyNoteClick}
className="sidebar-inbox-item"
title={t('sidebar.dailyNoteTooltip') || "Ouvrir ou créer la note personnelle pour le journal d'aujourd'hui"}
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0 bg-paper dark:bg-white/5 text-muted-ink border-border">
<CalendarDays size={14} />