fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup - Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders - Fix all SQL embedding queries: add ::vector cast on text columns - Fix embedding truncation to 15000 chars (under 8192 token limit) - Fix NoteEmbedding INSERT: remove non-existent updatedAt column - Fix billing page: show all quota stats in grid instead of single metric - Fix usage meter: accordion expand/collapse, per-feature detail - Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch - Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
This commit is contained in:
@@ -122,7 +122,7 @@ export function AdminSidebar({ className }: { className?: string }) {
|
||||
</DropdownMenu>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
href="/home"
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-2 rounded-xl px-2 py-1.5 transition-colors',
|
||||
'hover:bg-white/40 dark:hover:bg-white/10'
|
||||
@@ -143,7 +143,7 @@ export function AdminSidebar({ className }: { className?: string }) {
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
href="/home"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-4 py-2.5 text-[13px] font-medium transition-all',
|
||||
'text-muted-foreground hover:bg-white/40 hover:text-foreground dark:hover:bg-white/10'
|
||||
|
||||
@@ -205,7 +205,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-muted rounded-xl group-hover:bg-foreground group-hover:text-background transition-all">
|
||||
<div className="p-3 bg-muted rounded-xl group-hover:bg-brand-accent group-hover:text-white transition-all">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -224,7 +224,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
>
|
||||
<div className="relative inline-flex items-center cursor-pointer">
|
||||
<div className={`w-8 h-4 rounded-full transition-colors ${
|
||||
agent.isEnabled ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
agent.isEnabled ? 'bg-brand-accent' : 'bg-muted-foreground/30'
|
||||
}`}>
|
||||
<span className={`absolute top-0.5 left-[2px] bg-background border border-muted-foreground/30 rounded-full h-3 w-3 transition-all ${
|
||||
agent.isEnabled ? 'translate-x-4' : ''
|
||||
@@ -260,9 +260,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
<span className="uppercase tracking-tight">{t('agents.status.lastStatus')}</span>
|
||||
{lastAction ? (
|
||||
<span className={`flex items-center gap-1 ${
|
||||
lastAction.status === 'success' ? 'text-primary'
|
||||
lastAction.status === 'success' ? 'text-brand-accent'
|
||||
: lastAction.status === 'failure' ? 'text-destructive'
|
||||
: lastAction.status === 'running' ? 'text-primary'
|
||||
: lastAction.status === 'running' ? 'text-brand-accent'
|
||||
: 'text-muted-foreground'
|
||||
}`}>
|
||||
{lastAction.status === 'success' && <Activity className="w-2 h-2" />}
|
||||
|
||||
@@ -353,7 +353,7 @@ export function AgentDetailView({
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-4 bg-foreground text-background rounded-2xl shadow-xl shadow-foreground/10">
|
||||
<div className="p-4 bg-brand-accent text-white rounded-2xl shadow-xl shadow-brand-accent/10">
|
||||
<Icon className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -369,7 +369,7 @@ export function AgentDetailView({
|
||||
{isNew ? t('agents.newBadge') : `ID: ${agent?.id?.slice(0, 8)}`}
|
||||
</span>
|
||||
{!isNew && agent?.isEnabled && (
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-1 bg-primary/10 text-primary rounded-md">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-1 bg-brand-accent/10 text-brand-accent rounded-md">
|
||||
{t('agents.actions.toggleOn')}
|
||||
</span>
|
||||
)}
|
||||
@@ -386,7 +386,7 @@ export function AgentDetailView({
|
||||
{successRate !== null && (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="opacity-40">Succès</span>
|
||||
<span className="text-primary">{successRate}%</span>
|
||||
<span className="text-brand-accent">{successRate}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -751,7 +751,7 @@ export function AgentDetailView({
|
||||
<div className="space-y-10">
|
||||
<section className="space-y-6">
|
||||
<h3 className={sectionTitleCls}>{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></h3>
|
||||
<div className="bg-foreground rounded-3xl p-8 space-y-8 text-background shadow-2xl shadow-foreground/20 relative overflow-hidden">
|
||||
<div className="bg-ink rounded-3xl p-8 space-y-8 text-paper shadow-2xl shadow-ink/20 relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10">
|
||||
<Clock className="w-24 h-24" />
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,8 @@ import { DefaultChatTransport } from 'ai'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
X, Bot, Sparkles, History, Send, Globe, Briefcase, Palette, GraduationCap, Coffee,
|
||||
Loader2, Layers, Square, Plus, ChevronRight, MessageSquare, FileCode,
|
||||
Zap, Network, Clock, Scissors, Languages, Layout, ArrowRightLeft, BookOpen,
|
||||
X, Bot, Sparkles, History, Send, Globe,
|
||||
Loader2, Square, Plus, MessageSquare, FileCode,
|
||||
Maximize2, Minimize2
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -28,14 +27,7 @@ function getTextContent(msg: UIMessage): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
const TONE_IDS = [
|
||||
{ id: 'professional', icon: Briefcase },
|
||||
{ id: 'creative', icon: Palette },
|
||||
{ id: 'academic', icon: GraduationCap },
|
||||
{ id: 'casual', icon: Coffee },
|
||||
] as const
|
||||
|
||||
type AITab = 'discussion' | 'actions' | 'explore' | 'resources'
|
||||
type AITab = 'discussion' | 'history'
|
||||
|
||||
export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: boolean } = {}) {
|
||||
const { t, language } = useLanguage()
|
||||
@@ -45,7 +37,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [aiTab, setAiTab] = useState<AITab>('discussion')
|
||||
const [selectedTone, setSelectedTone] = useState('professional')
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [chatScope, setChatScope] = useState<'all' | string>('all')
|
||||
const [input, setInput] = useState('')
|
||||
@@ -55,9 +46,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
const [history, setHistory] = useState<any[]>([])
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
|
||||
const [insights, setInsights] = useState<string>('')
|
||||
const [insightsLoading, setInsightsLoading] = useState(false)
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
||||
|
||||
@@ -65,6 +53,23 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
transport,
|
||||
onError: (error) => {
|
||||
console.error('Chat error:', error)
|
||||
try {
|
||||
const parsed = JSON.parse((error as Error).message || '{}')
|
||||
if (parsed.error === 'QUOTA_EXCEEDED') {
|
||||
const isBasic = (parsed.currentTier || 'BASIC') === 'BASIC'
|
||||
toast.error(
|
||||
language === 'fr'
|
||||
? isBasic
|
||||
? 'Le chat IA est réservé au plan PRO et supérieur.'
|
||||
: `Limite mensuelle atteinte pour le plan ${parsed.currentTier}. Elle se réinitialise le mois prochain.`
|
||||
: isBasic
|
||||
? 'AI Chat is available from the PRO plan onwards.'
|
||||
: `Monthly quota reached for ${parsed.currentTier} plan. It will reset next month.`,
|
||||
{ duration: 8000 }
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
toast.error(t('chat.assistantError') || 'Chat error')
|
||||
}
|
||||
})
|
||||
@@ -91,7 +96,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
{ text },
|
||||
{
|
||||
body: {
|
||||
tone: selectedTone,
|
||||
chatScope,
|
||||
notebookId: chatScope !== 'all' ? chatScope : undefined,
|
||||
webSearch: webSearch && webSearchAvailable,
|
||||
@@ -115,24 +119,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
|
||||
const fetchInsights = async () => {
|
||||
setInsightsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/chat/insights')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setInsights(data.insight)
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
setInsightsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (aiTab === 'discussion') {
|
||||
// history is loaded on demand via insights tab
|
||||
}
|
||||
}, [aiTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -203,6 +189,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
title={t('general.close') || 'Fermer'}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -215,22 +202,28 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border px-2 shrink-0">
|
||||
{(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => {
|
||||
{(['discussion', 'history'] as AITab[]).map((tab) => {
|
||||
const labels: Record<AITab, string> = {
|
||||
discussion: t('ai.chatTab') || 'Discussion',
|
||||
actions: t('ai.actionsTab') || 'Actions',
|
||||
explore: t('ai.exploreTab') || 'Explorer',
|
||||
resources: t('ai.resourcesTab') || 'Ressources',
|
||||
history: t('ai.historyTab') || 'Historique',
|
||||
}
|
||||
const icons: Record<AITab, React.ReactNode> = {
|
||||
discussion: <MessageSquare size={12} />,
|
||||
history: <History size={12} />,
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setAiTab(tab)}
|
||||
onClick={() => {
|
||||
setAiTab(tab)
|
||||
if (tab === 'history') fetchHistory()
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative",
|
||||
"flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative flex items-center justify-center gap-1.5",
|
||||
aiTab === tab ? 'text-brand-accent' : 'text-concrete hover:text-ink/60'
|
||||
)}
|
||||
>
|
||||
{icons[tab]}
|
||||
{labels[tab]}
|
||||
{aiTab === tab && (
|
||||
<motion.div
|
||||
@@ -248,38 +241,15 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
<AnimatePresence mode="wait">
|
||||
{aiTab === 'discussion' && (
|
||||
<motion.div key="discussion" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
|
||||
{/* Context selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.writingTone')}</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{TONE_IDS.map((tone) => {
|
||||
const Icon = tone.icon
|
||||
return (
|
||||
<button
|
||||
key={tone.id}
|
||||
onClick={() => setSelectedTone(tone.id)}
|
||||
title={t(`ai.tones.${tone.id}`)}
|
||||
className={cn(
|
||||
'w-8 h-8 rounded-lg flex items-center justify-center text-[9px] font-bold transition-all border',
|
||||
selectedTone === tone.id
|
||||
? 'bg-brand-accent text-white border-brand-accent shadow-sm'
|
||||
: 'bg-white/50 dark:bg-white/5 border-border/40 text-concrete hover:border-brand-accent/40'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope selector */}
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setChatScope('all')}
|
||||
className={cn(
|
||||
'w-full p-2.5 border rounded-xl text-[11px] flex items-center justify-between transition-all',
|
||||
chatScope === 'all' ? 'bg-brand-accent/5 border-brand-accent/30' : 'bg-white/50 dark:bg-white/5 border-border/40 hover:border-ink/20'
|
||||
)}
|
||||
title={t('ai.allMyNotes') || 'Toutes mes notes'}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileCode size={14} className="text-brand-accent/60" />
|
||||
@@ -324,14 +294,14 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
<div key={msg.id} className={cn('flex gap-3', msg.role === 'user' && 'flex-row-reverse')}>
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
msg.role === 'user' ? 'bg-ink text-paper' : 'bg-brand-accent/10 text-brand-accent',
|
||||
msg.role === 'user' ? 'bg-brand-accent text-white' : 'bg-brand-accent/10 text-brand-accent',
|
||||
)}>
|
||||
{msg.role === 'user' ? 'U' : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed',
|
||||
msg.role === 'user'
|
||||
? 'bg-ink text-paper rounded-tr-sm'
|
||||
? 'bg-brand-accent text-white rounded-tr-sm'
|
||||
: 'bg-white/60 dark:bg-white/5 border border-border rounded-tl-sm text-ink',
|
||||
)}>
|
||||
{msg.role === 'assistant' ? <MarkdownContent content={text} /> : <p>{text}</p>}
|
||||
@@ -354,187 +324,36 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{aiTab === 'actions' && (
|
||||
<motion.div key="actions" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-8">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Transformations</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ icon: <Sparkles size={14} />, label: t('ai.actionClarify') || 'Clarifier' },
|
||||
{ icon: <Scissors size={14} />, label: t('ai.actionShorten') || 'Raccourcir' },
|
||||
{ icon: <Zap size={14} />, label: t('ai.actionImprove') || 'Améliorer' },
|
||||
{ icon: <Languages size={14} />, label: t('ai.actionTranslate') || 'Traduire' },
|
||||
].map((action, i) => (
|
||||
<button key={i} className="flex flex-col items-center gap-3 p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl transition-all group hover:border-ink/20">
|
||||
<div className="p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-brand-accent group-hover:text-white shadow-sm text-concrete">
|
||||
{action.icon}
|
||||
{aiTab === 'history' && (
|
||||
<motion.div key="history" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-3">
|
||||
{historyLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-concrete" />
|
||||
</div>
|
||||
) : history.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-concrete/30">
|
||||
<History size={24} />
|
||||
<p className="text-[11px] mt-3 italic">{t('ai.noHistory') || 'Aucun historique'}</p>
|
||||
</div>
|
||||
) : (
|
||||
history.map((conv: any) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => { setConversationId(conv.id); setMessages(conv.messages || []); setAiTab('discussion') }}
|
||||
className="w-full text-left p-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl hover:border-brand-accent/30 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-brand-accent/10 text-brand-accent group-hover:bg-brand-accent group-hover:text-white transition-colors">
|
||||
<MessageSquare size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-ink truncate">{conv.title || conv.id}</p>
|
||||
<p className="text-[10px] text-concrete mt-0.5">{new Date(conv.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-white/50 dark:bg-white/5 border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-white dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
|
||||
<FileCode size={14} className="text-concrete" />
|
||||
{t('ai.actionMarkdown') || 'Convertir en Markdown'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Generation Tools</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Layout size={80} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-brand-accent"><Layout size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-ink leading-none">{t('ai.slides') || 'Présentation'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.slidesDesc') || 'Convertir en slides interactives'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full py-3.5 bg-brand-accent text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-brand-accent/20 uppercase tracking-[0.2em]">
|
||||
{t('ai.generate') || 'Générer'} <ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-emerald-500/30 transition-all duration-500 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<BookOpen size={80} className="text-emerald-500" />
|
||||
</div>
|
||||
<div className="relative space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-50 rounded-lg text-emerald-500"><BookOpen size={18} /></div>
|
||||
<div className="space-y-0.5">
|
||||
<h5 className="text-sm font-bold text-ink leading-none">{t('ai.diagram') || 'Diagramme'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.diagramDesc') || 'Visualisation de structure'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="w-full py-3.5 bg-emerald-600 text-white rounded-xl text-[10px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-emerald-600/20 uppercase tracking-[0.2em]">
|
||||
{t('ai.trace') || 'Tracer'} <ArrowRightLeft size={14} className="opacity-60" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 opacity-20 py-4">
|
||||
<History size={16} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">Auto-Save Enabled</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{aiTab === 'explore' && (
|
||||
<motion.div key="explore" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-concrete whitespace-nowrap">Intelligence Modules</h4>
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-brand-accent/30 transition-all text-left overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Zap size={60} className="text-brand-accent" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-brand-accent/10 rounded-xl text-brand-accent group-hover:bg-brand-accent group-hover:text-white transition-colors">
|
||||
<Zap size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">{t('ai.brainstormWave') || 'Brainstorm Wave'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.brainstormWaveDesc') || 'Unfold dimensions of thought'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-indigo-500/30 transition-all text-left overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Network size={60} className="text-indigo-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-500/10 rounded-xl text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-colors">
|
||||
<Network size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">{t('ai.semanticNetwork') || 'Réseau Sémantique'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.semanticNetworkDesc') || 'Detect clusters and bridges'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-rose-500/30 transition-all text-left overflow-hidden">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Clock size={60} className="text-rose-500" />
|
||||
</div>
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="p-3 bg-rose-500/10 rounded-xl text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-colors">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-ink text-sm">{t('ai.temporalForecast') || 'Prévision Temporelle'}</h5>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-tight">{t('ai.temporalForecastDesc') || 'Predict relevance recurrence'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-50 dark:bg-white/5 border border-dashed border-border">
|
||||
<p className="text-[10px] text-concrete leading-relaxed font-medium italic text-center">
|
||||
{t('ai.modulesHint') || 'Ces modules utilisent les embeddings pour analyser vos pensées.'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{aiTab === 'resources' && (
|
||||
<motion.div key="resources" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resourceUrl') || 'URL (Optionnel)'}</label>
|
||||
<div className="relative">
|
||||
<input type="text" placeholder="https://..." className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl pl-4 pr-10 py-3 text-xs outline-none focus:border-brand-accent transition-colors text-ink" />
|
||||
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-concrete/40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.resourceText') || 'Texte de la ressource'}</label>
|
||||
<textarea
|
||||
rows={8}
|
||||
placeholder={t('ai.resourcePlaceholder') || 'Collez votre texte ici...'}
|
||||
className="w-full bg-white/50 dark:bg-white/5 border border-border rounded-xl p-4 text-xs outline-none focus:border-brand-accent transition-colors resize-none leading-relaxed text-ink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-concrete">{t('ai.integrationMode') || "Mode d'intégration"}</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'replace', label: t('ai.modeReplace') || 'Remplacer', sub: 'Direct' },
|
||||
{ id: 'append', label: t('ai.modeAppend') || 'Compléter', sub: 'Ajoute' },
|
||||
{ id: 'merge', label: t('ai.modeMerge') || 'Fusionner', sub: 'Intègre' },
|
||||
].map((mode) => (
|
||||
<button key={mode.id} className="flex flex-col items-center justify-center p-3 rounded-xl border transition-all text-center bg-white border-border hover:bg-slate-50 dark:hover:bg-white/5">
|
||||
<span className="text-[10px] font-bold text-ink">{mode.label}</span>
|
||||
<span className="text-[8px] text-concrete opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full py-4 bg-brand-accent text-white rounded-xl text-[11px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-brand-accent/20">
|
||||
<Sparkles size={18} />
|
||||
{t('ai.generatePreview') || "Générer l'aperçu"}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -561,6 +380,15 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
|
||||
<button
|
||||
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
|
||||
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
|
||||
title={webSearch ? (t('ai.webSearchEnabled') || 'Recherche web activée') : (t('ai.webSearchDisabled') || 'Activer la recherche web')}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute right-4 bottom-4 flex flex-col gap-2">
|
||||
{isLoading ? (
|
||||
<button onClick={() => stop()} className="p-2.5 bg-rose-500 text-white rounded-xl transition-all hover:scale-110 active:scale-95 shadow-lg shadow-rose-500/20">
|
||||
@@ -572,18 +400,6 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute left-6 bottom-4 flex gap-3 text-concrete/40">
|
||||
<button
|
||||
onClick={() => webSearchAvailable && setWebSearch(!webSearch)}
|
||||
className={cn("hover:text-brand-accent transition-colors", webSearch && "text-brand-accent")}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</button>
|
||||
<button className="hover:text-brand-accent transition-colors"><Network size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center mt-4">
|
||||
<p className="text-[9px] text-concrete/40 uppercase tracking-[0.3em] font-bold">Shift+Enter for new line</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { Note } from '@/lib/types'
|
||||
import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
@@ -41,11 +41,9 @@ export function ArchiveClient({ notes }: ArchiveClientProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageEntry>
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export function BrainstormPage() {
|
||||
setConvertToast({ noteTitle: result.title || idea.title, noteId: result.id })
|
||||
setTimeout(() => {
|
||||
setConvertToast(null)
|
||||
router.push(`/?openNote=${result.id}`)
|
||||
router.push(`/home?openNote=${result.id}`)
|
||||
}, 2000)
|
||||
}
|
||||
} catch {}
|
||||
@@ -228,7 +228,7 @@ export function BrainstormPage() {
|
||||
setExportToast({ noteTitle: result.title || t('brainstorm.exportDefaultNoteTitle'), notebookName })
|
||||
setTimeout(() => {
|
||||
setExportToast(null)
|
||||
router.push(`/?openNote=${result.id}`)
|
||||
router.push(`/home?openNote=${result.id}`)
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
@@ -634,7 +634,7 @@ export function BrainstormPage() {
|
||||
</div>
|
||||
{ref.noteId && (
|
||||
<button
|
||||
onClick={() => router.push(`/?openNote=${ref.noteId}`)}
|
||||
onClick={() => router.push(`/home?openNote=${ref.noteId}`)}
|
||||
className="shrink-0 px-2 py-1 text-[9px] font-bold uppercase tracking-wider rounded-lg bg-foreground/5 hover:bg-foreground/10 text-muted-foreground hover:text-foreground transition-all"
|
||||
>
|
||||
{t('brainstorm.viewNote') || 'View'}
|
||||
|
||||
@@ -137,8 +137,8 @@ export function BrainstormShareDialog({
|
||||
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-foreground">
|
||||
<div className="w-7 h-7 rounded-lg bg-orange-500/10 flex items-center justify-center">
|
||||
<UserPlus size={14} className="text-orange-500" />
|
||||
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center">
|
||||
<UserPlus size={14} className="text-brand-accent" />
|
||||
</div>
|
||||
<span className="font-serif">{t('brainstorm.shareDialogTitle')}</span>
|
||||
</DialogTitle>
|
||||
@@ -159,7 +159,7 @@ export function BrainstormShareDialog({
|
||||
onFocus={() => results.length > 0 && setShowDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
||||
placeholder={t('brainstorm.shareNameOrEmailPlaceholder')}
|
||||
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500/40 transition-all"
|
||||
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-brand-accent/20 focus:border-brand-accent/40 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -173,9 +173,9 @@ export function BrainstormShareDialog({
|
||||
e.preventDefault()
|
||||
selectUser(user)
|
||||
}}
|
||||
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-orange-500/5 transition-colors text-left"
|
||||
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-brand-accent/5 transition-colors text-left"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-orange-500/10 flex items-center justify-center text-xs font-bold text-orange-600 shrink-0">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-accent/10 flex items-center justify-center text-xs font-bold text-brand-accent shrink-0">
|
||||
{(user.name || user.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -214,7 +214,7 @@ export function BrainstormShareDialog({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!query.trim() || isPending}
|
||||
className="w-full py-3 bg-orange-500 hover:bg-orange-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
|
||||
className="w-full py-3 bg-brand-accent hover:bg-brand-accent/90 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
{isPending ? t('brainstorm.shareSubmitting') : t('brainstorm.shareSubmit')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
|
||||
interface GhostCursorProps {
|
||||
@@ -10,114 +10,132 @@ interface GhostCursorProps {
|
||||
}
|
||||
|
||||
export function GhostCursor({ isActive, containerRef, targetId }: GhostCursorProps) {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [visible, setVisible] = useState(false)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const positionRef = useRef({ x: 0, y: 0 })
|
||||
const visibleRef = useRef(false)
|
||||
const elRef = useRef<HTMLDivElement>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const targetRef = useRef({ x: 0, y: 0 })
|
||||
const initializedRef = useRef(false)
|
||||
const targetIdRef = useRef(targetId)
|
||||
targetIdRef.current = targetId
|
||||
const isActiveRef = useRef(isActive)
|
||||
isActiveRef.current = isActive
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setVisible(false)
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
visibleRef.current = false
|
||||
initializedRef.current = false
|
||||
if (elRef.current) elRef.current.style.opacity = '0'
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
setVisible(true)
|
||||
|
||||
// Initialisation sécurisée
|
||||
const initialRect = container.getBoundingClientRect()
|
||||
const initialCx = initialRect.width > 0 ? initialRect.width / 2 : window.innerWidth / 2
|
||||
const initialCy = initialRect.height > 0 ? initialRect.height / 2 : window.innerHeight / 2
|
||||
|
||||
setPosition({
|
||||
x: initialCx + (Math.random() - 0.5) * 200,
|
||||
y: initialCy + (Math.random() - 0.5) * 200,
|
||||
})
|
||||
|
||||
let angle = Math.random() * Math.PI * 2
|
||||
let targetX = initialCx + Math.cos(angle) * 250
|
||||
let targetY = initialCy + Math.sin(angle) * 250
|
||||
|
||||
intervalRef.current = setInterval(() => {
|
||||
// Recalculer les dimensions à chaque tick pour s'adapter aux redimensionnements
|
||||
const currentContainer = containerRef.current
|
||||
if (!currentContainer) return
|
||||
const init = () => {
|
||||
const container = containerRef.current
|
||||
if (!container) { rafRef.current = requestAnimationFrame(init); return }
|
||||
const rect = container.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) { rafRef.current = requestAnimationFrame(init); return }
|
||||
|
||||
const cx = rect.width / 2
|
||||
const cy = rect.height / 2
|
||||
targetRef.current = { x: cx + (Math.random() - 0.5) * 200, y: cy + (Math.random() - 0.5) * 200 }
|
||||
positionRef.current = { ...targetRef.current }
|
||||
initializedRef.current = true
|
||||
visibleRef.current = true
|
||||
if (elRef.current) {
|
||||
elRef.current.style.opacity = '1'
|
||||
elRef.current.style.transform = `translate(${positionRef.current.x}px, ${positionRef.current.y}px)`
|
||||
}
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(init)
|
||||
|
||||
const tick = () => {
|
||||
if (!isActiveRef.current || !initializedRef.current) {
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (!container) { rafRef.current = requestAnimationFrame(tick); return }
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
if (containerRect.width === 0 || containerRect.height === 0) { rafRef.current = requestAnimationFrame(tick); return }
|
||||
|
||||
const containerRect = currentContainer.getBoundingClientRect()
|
||||
const cx = containerRect.width / 2
|
||||
const cy = containerRect.height / 2
|
||||
let tx = targetRef.current.x
|
||||
let ty = targetRef.current.y
|
||||
|
||||
let currentTargetX = targetX;
|
||||
let currentTargetY = targetY;
|
||||
|
||||
if (targetId) {
|
||||
const nodeElement = document.querySelector(`[data-id="${targetId}"]`);
|
||||
const currentTargetId = targetIdRef.current
|
||||
if (currentTargetId) {
|
||||
const nodeElement = container.querySelector(`[data-id="${currentTargetId}"]`) as HTMLElement | null
|
||||
if (nodeElement) {
|
||||
const nodeRect = nodeElement.getBoundingClientRect();
|
||||
currentTargetX = nodeRect.left - containerRect.left + nodeRect.width / 2;
|
||||
currentTargetY = nodeRect.top - containerRect.top + nodeRect.height / 2;
|
||||
const nodeRect = nodeElement.getBoundingClientRect()
|
||||
tx = nodeRect.left - containerRect.left + nodeRect.width / 2
|
||||
ty = nodeRect.top - containerRect.top + nodeRect.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
setPosition(prev => {
|
||||
const dx = currentTargetX - prev.x
|
||||
const dy = currentTargetY - prev.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const prev = positionRef.current
|
||||
const dx = tx - prev.x
|
||||
const dy = ty - prev.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (!targetId && dist < 20) {
|
||||
angle = Math.random() * Math.PI * 2
|
||||
const radius = 150 + Math.random() * 200
|
||||
targetX = cx + Math.cos(angle) * radius
|
||||
targetY = cy + Math.sin(angle) * radius
|
||||
}
|
||||
if (!currentTargetId && dist < 20) {
|
||||
angle = Math.random() * Math.PI * 2
|
||||
const radius = 150 + Math.random() * 200
|
||||
tx = cx + Math.cos(angle) * radius
|
||||
ty = cy + Math.sin(angle) * radius
|
||||
targetRef.current = { x: tx, y: ty }
|
||||
}
|
||||
|
||||
const speed = targetId ? 0.15 : 0.06;
|
||||
|
||||
let newX = prev.x + dx * speed;
|
||||
let newY = prev.y + dy * speed;
|
||||
|
||||
// Protection Anti-NaN qui bloquait le curseur en haut à gauche
|
||||
if (isNaN(newX)) newX = cx;
|
||||
if (isNaN(newY)) newY = cy;
|
||||
const speed = currentTargetId ? 0.12 : 0.04
|
||||
let newX = prev.x + (tx - prev.x) * speed
|
||||
let newY = prev.y + (ty - prev.y) * speed
|
||||
if (isNaN(newX)) newX = cx
|
||||
if (isNaN(newY)) newY = cy
|
||||
|
||||
return {
|
||||
x: newX,
|
||||
y: newY,
|
||||
}
|
||||
})
|
||||
}, 50)
|
||||
positionRef.current = { x: newX, y: newY }
|
||||
|
||||
if (elRef.current) {
|
||||
elRef.current.style.transform = `translate(${newX}px, ${newY}px)`
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [isActive, containerRef, targetId])
|
||||
}, [isActive, containerRef])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
className="absolute pointer-events-none z-50"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
>
|
||||
<div className="relative">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill="#a78bfa" />
|
||||
</svg>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-50" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
|
||||
</div>
|
||||
<div className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg bg-gradient-to-r from-violet-500 to-purple-600">
|
||||
AI ✦
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
ref={elRef}
|
||||
className="absolute pointer-events-none z-50"
|
||||
style={{
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill="#a78bfa" />
|
||||
</svg>
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-50" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
|
||||
</div>
|
||||
<div className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg bg-gradient-to-r from-violet-500 to-purple-600">
|
||||
AI ✦
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -564,16 +564,16 @@ export function ContextualAIChat({
|
||||
|
||||
<div className="p-6 border-b border-border/60 space-y-1.5 bg-white/50 dark:bg-black/20 backdrop-blur-md shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
|
||||
<Sparkles size={18} className="text-brand-accent" />
|
||||
<Sparkles size={18} className="text-brand-accent shrink-0" />
|
||||
{t('ai.assistantTitle') || 'IA Assistant'}
|
||||
</h3>
|
||||
<p className="text-[11px] text-concrete uppercase tracking-wider font-medium opacity-60 truncate">
|
||||
"{noteTitle || t('ai.currentNote')}"
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
@@ -584,6 +584,7 @@ export function ContextualAIChat({
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded-lg transition-colors text-concrete"
|
||||
title={t('general.close') || 'Fermer'}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,10 @@ export function HierarchicalNotebookSelector({
|
||||
const getDropdownStyle = useCallback((): React.CSSProperties => {
|
||||
if (!triggerRef.current) return { position: 'fixed', top: 0, left: 0, width: 280, zIndex: 9999 }
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
if (dropUp) {
|
||||
const dropdownHeight = 350
|
||||
const viewportHeight = window.innerHeight
|
||||
const wouldOverflowBottom = rect.bottom + 8 + dropdownHeight > viewportHeight
|
||||
if (dropUp || wouldOverflowBottom) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { NoteHistoryModal } from '@/components/note-history-modal'
|
||||
import { CreateNotebookDialog } from '@/components/create-notebook-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { PageEntry } from '@/components/page-entry'
|
||||
|
||||
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
|
||||
@@ -111,7 +111,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
setEditingNote(null)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('forceList')
|
||||
const newUrl = params.toString() ? `/?${params.toString()}` : '/'
|
||||
const newUrl = params.toString() ? `/home?${params.toString()}` : '/home'
|
||||
router.replace(newUrl, { scroll: false })
|
||||
}
|
||||
}, [searchParams, router])
|
||||
@@ -441,7 +441,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('openNote')
|
||||
const qs = params.toString()
|
||||
router.replace(qs ? `/?${qs}` : '/', { scroll: false })
|
||||
router.replace(qs ? `/home?${qs}` : '/home', { scroll: false })
|
||||
// Invalidate notes cache and trigger refresh
|
||||
refreshNotes(searchParams.get('notebook') || null)
|
||||
}, [refreshNotes, router, searchParams])
|
||||
@@ -457,7 +457,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
}, [refreshNotes])
|
||||
|
||||
return (
|
||||
<PageEntry>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-h-0 flex-1 flex-col gap-3 py-1'
|
||||
@@ -543,7 +542,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
} else {
|
||||
params.delete('search')
|
||||
}
|
||||
router.push(`/?${params.toString()}`)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!inlineSearchQuery) {
|
||||
@@ -556,7 +555,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
setInlineSearchQuery('')
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('search')
|
||||
router.push(`/?${params.toString()}`)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
}}
|
||||
placeholder={t('search.placeholder') || 'Rechercher...'}
|
||||
@@ -569,7 +568,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
setInlineSearchQuery('')
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.delete('search')
|
||||
router.push(`/?${params.toString()}`)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
@@ -837,6 +836,5 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageEntry>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,10 +100,10 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground ms-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[280px] p-2 rounded-2xl shadow-xl border-muted/20">
|
||||
<DropdownMenuContent align="start" className="w-[280px] p-2 rounded-2xl shadow-xl border-border/40 bg-paper dark:bg-dark-paper">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground px-2 py-1.5 flex justify-between items-center">
|
||||
{t('labHeader.yourSpaces')}
|
||||
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full font-mono">{canvases.length}</span>
|
||||
<span className="text-[10px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.5 rounded-full font-mono">{canvases.length}</span>
|
||||
</DropdownMenuLabel>
|
||||
<div className="space-y-1 mt-1">
|
||||
{canvases.map(c => (
|
||||
@@ -111,13 +111,13 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-start gap-0.5 rounded-xl cursor-pointer p-3 transition-all",
|
||||
c.id === currentCanvasId ? "bg-primary/5 text-primary border border-primary/20" : "hover:bg-muted"
|
||||
c.id === currentCanvasId ? "bg-brand-accent/5 text-brand-accent border border-brand-accent/20" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => router.push(`/lab?id=${c.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="font-semibold text-sm">{c.name}</span>
|
||||
{c.id === currentCanvasId && <span className="w-2 h-2 rounded-full bg-primary" />}
|
||||
{c.id === currentCanvasId && <span className="w-2 h-2 rounded-full bg-brand-accent" />}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{t('labHeader.updated')} {new Date(c.updatedAt).toLocaleDateString()}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -139,7 +139,7 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
<DropdownMenuItem
|
||||
onClick={handleCreate}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-2 text-primary font-medium p-3 rounded-xl cursor-pointer hover:bg-primary/5"
|
||||
className="flex items-center gap-2 text-brand-accent font-medium p-3 rounded-xl cursor-pointer hover:bg-brand-accent/5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('labHeader.newSpace')}
|
||||
|
||||
412
memento-note/components/landing-page.tsx
Normal file
412
memento-note/components/landing-page.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
BrainCircuit, Search, MessageSquare, Zap, Cpu, Workflow,
|
||||
Globe, Shield, ArrowRight, Sparkles, Layers, Activity,
|
||||
Box
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function LandingPage() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly')
|
||||
|
||||
const AGENTS = [
|
||||
{ key: 'scraper', icon: <Globe size={24} /> },
|
||||
{ key: 'researcher', icon: <Search size={24} /> },
|
||||
{ key: 'slideGen', icon: <Layers size={24} /> },
|
||||
{ key: 'monitor', icon: <Activity size={24} /> },
|
||||
{ key: 'diagramGen', icon: <Box size={24} /> },
|
||||
{ key: 'custom', icon: <Workflow size={24} /> },
|
||||
]
|
||||
|
||||
const BRAINSTORM_ITEMS = ['waveGeneration', 'collaboration', 'export']
|
||||
|
||||
const PLANS = [
|
||||
{ key: 'basic', popular: false, price: t('landing.pricing.basicPrice'), period: '' },
|
||||
{ key: 'pro', popular: true, price: billingInterval === 'monthly' ? '9,90€' : '7,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
||||
{ key: 'business', popular: false, price: billingInterval === 'monthly' ? '29,90€' : '23,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perMonth') : t('landing.pricing.perMonthAnnual') },
|
||||
{ key: 'enterprise', popular: false, price: billingInterval === 'monthly' ? '49,90€' : '39,90€', period: billingInterval === 'monthly' ? t('landing.pricing.perUser') : t('landing.pricing.perUserAnnual') },
|
||||
]
|
||||
|
||||
const TECH_TIERS = [
|
||||
{ key: 'tags', color: 'bg-brand-accent' },
|
||||
{ key: 'embeddings', color: 'bg-ochre' },
|
||||
{ key: 'chatRag', color: 'bg-ink' },
|
||||
]
|
||||
|
||||
const FOOTER_SECTIONS = ['product', 'community', 'legal'] as const
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-paper text-ink font-sans selection:bg-ochre/30 selection:text-ink">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-[100] bg-paper/80 backdrop-blur-md border-b border-border px-8 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-ink flex items-center justify-center rounded-xl shadow-lg rotate-3 group hover:rotate-0 transition-transform cursor-pointer">
|
||||
<span className="text-paper font-serif text-2xl font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-2xl font-medium tracking-tight">Momento</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex items-center gap-10">
|
||||
<a href="#features" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.features')}</a>
|
||||
<a href="#agents" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.agents')}</a>
|
||||
<a href="#brainstorm" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.brainstorm')}</a>
|
||||
<a href="#pricing" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.pricing')}</a>
|
||||
<a href="#tech" className="text-[11px] font-bold uppercase tracking-widest text-concrete hover:text-ink transition-colors">{t('landing.nav.tech')}</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={() => router.push('/login')} className="hidden md:block px-6 py-2.5 text-concrete hover:text-ink text-[11px] font-bold uppercase tracking-widest transition-colors">
|
||||
{t('landing.nav.login')}
|
||||
</button>
|
||||
<button onClick={() => router.push('/register')} className="px-6 py-2.5 bg-ink text-paper rounded-full text-[11px] font-bold uppercase tracking-widest hover:opacity-90 transition-all flex items-center gap-2 group shadow-xl shadow-ink/10">
|
||||
{t('landing.nav.cta')}
|
||||
<ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative pt-40 pb-32 px-8 overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-ochre/5 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/4 -z-10" />
|
||||
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-brand-accent/5 rounded-full blur-[100px] translate-y-1/2 -translate-x-1/4 -z-10" />
|
||||
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<motion.div initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease: [0.23, 1, 0.32, 1] }}>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-ochre/10 border border-ochre/20 text-ochre text-[10px] font-bold uppercase tracking-[0.2em] mb-8">
|
||||
<Sparkles size={12} />
|
||||
{t('landing.hero.badge')}
|
||||
</div>
|
||||
<h1 className="text-6xl md:text-8xl font-serif font-medium tracking-tight text-ink mb-8 leading-[1.1]">
|
||||
{t('landing.hero.title1')} <br />
|
||||
<span className="italic">{t('landing.hero.title2')}</span>
|
||||
</h1>
|
||||
<p className="max-w-2xl mx-auto text-lg md:text-xl text-concrete font-light leading-relaxed mb-12">
|
||||
{t('landing.hero.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button onClick={() => router.push('/register')} className="px-10 py-5 bg-ink text-paper rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-95 transition-all shadow-2xl shadow-ink/20 flex items-center gap-4 group">
|
||||
{t('landing.hero.cta')}
|
||||
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<a href="#features" className="px-10 py-5 border border-border rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:bg-slate-50 transition-all">
|
||||
{t('landing.hero.secondary')}
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* App Preview Mockup */}
|
||||
<motion.div initial={{ opacity: 0, y: 100 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 1, delay: 0.2, ease: [0.23, 1, 0.32, 1] }} className="mt-24 relative">
|
||||
<div className="relative mx-auto max-w-5xl aspect-[16/10] bg-white rounded-[32px] shadow-[0_40px_100px_-20px_rgba(0,0,0,0.15)] border border-border p-4 overflow-hidden group">
|
||||
<img src="/images/workspace-hero.jpg" alt="Momento Workspace" className="w-full h-full object-cover rounded-2xl filter saturate-[0.8]" />
|
||||
<div className="absolute inset-0 bg-ink/10 group-hover:bg-ink/0 transition-colors duration-500" />
|
||||
|
||||
<div className="absolute top-10 right-10 w-64 bg-paper/90 backdrop-blur-xl border border-border p-6 rounded-2xl shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
|
||||
<BrainCircuit size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">{t('landing.hero.memoryEcho')}</span>
|
||||
</div>
|
||||
<p className="text-xs font-serif italic text-ink/70">{t('landing.hero.memoryEchoText')}</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-10 left-10 w-72 bg-ink text-paper p-6 rounded-2xl shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Activity size={16} className="text-ochre" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ochre">{t('landing.hero.brainstormLive')}</span>
|
||||
</div>
|
||||
<div className="flex items-center -space-x-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="w-6 h-6 rounded-full border-2 border-ink bg-concrete text-[8px] flex items-center justify-center font-bold">JD</div>
|
||||
))}
|
||||
<span className="text-[10px] ml-4 text-paper/60">{t('landing.hero.ideasGenerated')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section id="features" className="py-32 px-8 bg-paper">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-24 flex flex-col md:flex-row md:items-end justify-between gap-8">
|
||||
<div className="max-w-2xl">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.features.label')}</span>
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink">{t('landing.features.title')} <br />{t('landing.features.title2')}</h2>
|
||||
</div>
|
||||
<div className="text-concrete font-light">{t('landing.features.desc')}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
{['f1', 'f2', 'f3'].map((f, i) => {
|
||||
const icons = [
|
||||
<Search key="s" className="text-brand-accent" />,
|
||||
<MessageSquare key="m" className="text-ochre" />,
|
||||
<Zap key="z" className="text-ink" />
|
||||
]
|
||||
return (
|
||||
<div key={f} className="group">
|
||||
<div className="w-14 h-14 bg-slate-50 border border-border rounded-2xl flex items-center justify-center mb-8 group-hover:bg-ink group-hover:text-paper transition-all duration-300">
|
||||
{icons[i]}
|
||||
</div>
|
||||
<h3 className="text-xl font-serif font-medium mb-4">{t(`landing.features.${f}Title`)}</h3>
|
||||
<p className="text-sm text-concrete leading-relaxed font-light">{t(`landing.features.${f}Desc`)}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Agents */}
|
||||
<section id="agents" className="py-32 px-8 bg-ink text-paper overflow-hidden relative">
|
||||
<div className="absolute top-0 right-0 w-[1000px] h-[1000px] bg-brand-accent/10 rounded-full blur-[150px] -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="max-w-6xl mx-auto relative z-10">
|
||||
<div className="text-center mb-24">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.agents.label')}</span>
|
||||
<h2 className="text-4xl md:text-6xl font-serif tracking-tight mb-8">{t('landing.agents.title')}</h2>
|
||||
<p className="text-paper/60 max-w-xl mx-auto font-light">{t('landing.agents.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{AGENTS.map((agent, i) => (
|
||||
<div key={i} className="p-8 rounded-3xl bg-white/5 border border-white/10 hover:bg-white/10 transition-all cursor-default group">
|
||||
<div className="text-ochre mb-6 transition-transform group-hover:scale-110 duration-300">{agent.icon}</div>
|
||||
<h4 className="text-xl font-serif font-medium mb-4">{t(`landing.agents.${agent.key}.title`)}</h4>
|
||||
<p className="text-sm text-paper/50 leading-relaxed font-light">{t(`landing.agents.${agent.key}.desc`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Brainstorm */}
|
||||
<section id="brainstorm" className="py-32 px-8 bg-paper">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center gap-24">
|
||||
<div className="flex-1">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.brainstorm.label')}</span>
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-8 leading-tight">{t('landing.brainstorm.title')}</h2>
|
||||
<div className="space-y-8">
|
||||
{BRAINSTORM_ITEMS.map((item, i) => (
|
||||
<div key={item} className="flex gap-6">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-ochre/10 text-ochre flex items-center justify-center font-bold text-xs">{i + 1}</div>
|
||||
<div>
|
||||
<h5 className="font-bold text-sm mb-1">{t(`landing.brainstorm.${item}.title`)}</h5>
|
||||
<p className="text-sm text-concrete font-light leading-relaxed">{t(`landing.brainstorm.${item}.desc`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 relative">
|
||||
<div className="w-[450px] h-[450px] border-2 border-dashed border-border rounded-full flex items-center justify-center relative">
|
||||
<div className="absolute top-0 right-1/2 translate-x-1/2 -translate-y-1/2 w-4 h-4 bg-ink rounded-full" />
|
||||
<div className="absolute bottom-0 right-1/2 translate-x-1/2 translate-y-1/2 w-4 h-4 bg-ochre rounded-full" />
|
||||
<div className="w-[300px] h-[300px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
|
||||
<div className="w-[150px] h-[150px] border-2 border-dashed border-border rounded-full flex items-center justify-center">
|
||||
<div className="w-12 h-12 bg-ink rounded-xl shadow-2xl flex items-center justify-center text-paper font-serif text-xl">M</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-10 right-0 p-4 bg-white border border-border rounded-xl shadow-xl">
|
||||
<p className="text-[10px] font-bold text-ochre">{t('landing.brainstorm.disruptionLabel')}</p>
|
||||
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.disruptionText')}</p>
|
||||
</div>
|
||||
<div className="absolute bottom-20 -left-10 p-4 bg-white border border-border rounded-xl shadow-xl">
|
||||
<p className="text-[10px] font-bold text-brand-accent">{t('landing.brainstorm.analogyLabel')}</p>
|
||||
<p className="text-xs font-serif italic text-ink">{t('landing.brainstorm.analogyText')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tech */}
|
||||
<section id="tech" className="py-32 px-8 bg-slate-50 border-y border-border">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.tech.label')}</span>
|
||||
<h2 className="text-4xl font-serif tracking-tight mb-16">{t('landing.tech.title')}</h2>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-8 grayscale opacity-50 hover:grayscale-0 hover:opacity-100 transition-all duration-700">
|
||||
{['OpenAI', 'Google', 'Anthropic', 'DeepSeek', 'Mistral', 'Meta', 'Ollama', 'Groq', 'X.AI', 'Custom'].map((brand, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 bg-white rounded-xl border border-border flex items-center justify-center text-xs font-black tracking-tighter">
|
||||
{brand.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">{brand}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-24 max-w-2xl mx-auto p-1 bg-white rounded-3xl border border-border shadow-sm flex flex-col md:flex-row gap-0.5">
|
||||
{TECH_TIERS.map((tier, i) => (
|
||||
<div key={i} className="flex-1 p-6 text-left">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${tier.color} mb-4`} />
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-2">{t(`landing.tech.${tier.key}.title`)}</h6>
|
||||
<p className="text-xs font-light text-concrete">{t(`landing.tech.${tier.key}.desc`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section id="pricing" className="py-32 px-8 bg-paper">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.3em] text-ochre mb-4 block">{t('landing.pricing.label')}</span>
|
||||
<h2 className="text-4xl md:text-5xl font-serif tracking-tight text-ink mb-6">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-concrete font-light max-w-xl mx-auto mb-12">{t('landing.pricing.desc')}</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-10 mb-8">
|
||||
<button onClick={() => setBillingInterval('monthly')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'monthly' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.monthly')}</span>
|
||||
{billingInterval === 'monthly' && (
|
||||
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button onClick={() => setBillingInterval('annual')} className={`group relative py-2 px-1 transition-all ${billingInterval === 'annual' ? 'text-ink' : 'text-concrete/40 hover:text-concrete'}`}>
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">{t('landing.pricing.annual')}</span>
|
||||
{billingInterval === 'annual' && (
|
||||
<motion.div layoutId="interval-active" className="absolute -inset-x-1 -inset-y-0.5 border border-ochre/60" transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }} />
|
||||
)}
|
||||
</button>
|
||||
<div className="absolute -top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
|
||||
<span className="text-[9px] font-bold text-ochre uppercase tracking-widest italic animate-pulse">(-20%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
||||
{PLANS.map((plan, i) => (
|
||||
<div key={plan.key} className={`relative p-8 rounded-[32px] border flex flex-col transition-all duration-300 hover:shadow-2xl hover:shadow-ink/5 ${plan.popular ? 'bg-ink text-paper border-ink ring-4 ring-ochre/20' : 'bg-white border-border text-ink'}`}>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-ochre text-ink text-[10px] font-bold uppercase tracking-widest rounded-full">
|
||||
{t('landing.pricing.popular')}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-8">
|
||||
<h4 className="text-[11px] font-bold uppercase tracking-widest mb-2 opacity-60">{t(`landing.pricing.${plan.key}.name`)}</h4>
|
||||
<div className="flex items-baseline gap-1 mb-4">
|
||||
<span className="text-4xl font-serif font-medium">{plan.price}</span>
|
||||
{plan.period && <span className="text-xs opacity-60">{plan.period}</span>}
|
||||
</div>
|
||||
<p className="text-sm font-light leading-relaxed opacity-80">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
</div>
|
||||
<div className="flex-1 space-y-4 mb-10">
|
||||
{[0, 1, 2, 3, 4, 5].map(j => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||
if (!feat || feat === `landing.pricing.${plan.key}.feature${j}`) return null
|
||||
return (
|
||||
<div key={j} className="flex items-start gap-3">
|
||||
<div className={`mt-1 rounded-full p-0.5 ${plan.popular ? 'bg-ochre text-ink' : 'bg-brand-accent/10 text-brand-accent'}`}>
|
||||
<Shield size={10} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-xs font-light">{feat}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button onClick={() => router.push('/register')} className={`w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-widest transition-all ${plan.popular ? 'bg-ochre text-ink hover:opacity-90' : 'bg-ink text-paper hover:bg-ink/90'}`}>
|
||||
{t(`landing.pricing.${plan.key}.cta`)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* BYOK */}
|
||||
<div className="mt-20 p-12 bg-slate-50 border border-border rounded-[40px] flex flex-col md:flex-row items-center gap-12">
|
||||
<div className="flex-1">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-brand-accent/10 text-brand-accent text-[9px] font-bold uppercase tracking-widest mb-6">
|
||||
<Cpu size={12} />
|
||||
{t('landing.byok.label')}
|
||||
</div>
|
||||
<h3 className="text-3xl font-serif font-medium mb-4">{t('landing.byok.title')}</h3>
|
||||
<p className="text-concrete font-light leading-relaxed mb-6">{t('landing.byok.desc')}</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white rounded-2xl border border-border">
|
||||
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.noLockin')}</h5>
|
||||
<p className="text-[10px] text-concrete font-light">{t('landing.byok.noLockinDesc')}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-2xl border border-border">
|
||||
<h5 className="text-[10px] font-bold uppercase tracking-widest mb-2">{t('landing.byok.cost')}</h5>
|
||||
<p className="text-[10px] text-concrete font-light">{t('landing.byok.costDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-[400px] bg-ink rounded-3xl p-8 relative overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-brand-accent/10 blur-[50px] group-hover:bg-ochre/10 transition-colors" />
|
||||
<div className="relative z-10 font-mono text-[10px] text-paper/40 space-y-2">
|
||||
<p className="text-ochre">{"{"}</p>
|
||||
<p className="pl-4">"provider": "anthropic",</p>
|
||||
<p className="pl-4">"model": "claude-3-opus",</p>
|
||||
<p className="pl-4 border-l border-brand-accent/30 bg-brand-accent/5">"apiKey": "sk-ant-at03-..."</p>
|
||||
<p className="pl-4">"useSystemKey": false</p>
|
||||
<p className="text-ochre">{"}"}</p>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-between relative z-10">
|
||||
<span className="text-[10px] font-bold text-paper uppercase tracking-widest">{t('landing.byok.configLabel')}</span>
|
||||
<div className="flex gap-1">{[1, 2, 3].map(i => <div key={i} className="w-1 h-1 rounded-full bg-paper/20" />)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="py-40 px-8 text-center bg-paper relative overflow-hidden">
|
||||
<div className="max-w-3xl mx-auto relative z-10">
|
||||
<h2 className="text-5xl md:text-7xl font-serif tracking-tight mb-8 leading-tight">{t('landing.cta.title1')} <br /><span className="italic">{t('landing.cta.title2')}</span></h2>
|
||||
<p className="text-lg text-concrete font-light mb-12">{t('landing.cta.desc')}</p>
|
||||
<button onClick={() => router.push('/register')} className="px-16 py-6 bg-ink text-paper rounded-[32px] text-lg font-bold uppercase tracking-[0.2em] hover:scale-105 transition-all shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)]">
|
||||
{t('landing.cta.button')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-20 px-8 border-t border-border bg-paper">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row justify-between gap-12">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-ink flex items-center justify-center rounded-lg">
|
||||
<span className="text-paper font-serif text-lg font-bold">M</span>
|
||||
</div>
|
||||
<span className="font-serif text-xl font-medium tracking-tight">Momento</span>
|
||||
</div>
|
||||
<p className="text-sm text-concrete font-light max-w-xs">{t('landing.footer.desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-16">
|
||||
{FOOTER_SECTIONS.map(section => (
|
||||
<div key={section} className="space-y-4">
|
||||
<h6 className="text-[10px] font-bold uppercase tracking-widest text-ink">{t(`landing.footer.${section}.title`)}</h6>
|
||||
<ul className="space-y-2 text-sm text-concrete font-light">
|
||||
{[0, 1, 2].map(j => {
|
||||
const label = t(`landing.footer.${section}.link${j}`)
|
||||
const href = t(`landing.footer.${section}.link${j}Href`)
|
||||
if (!label || label.startsWith('landing.')) return null
|
||||
return <li key={j}><a href={href} className="hover:text-ink">{label}</a></li>
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-6xl mx-auto mt-20 pt-10 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] uppercase font-bold tracking-widest text-concrete">
|
||||
<div>© 2026 MOMENTO LABS. ALL RIGHTS RESERVED.</div>
|
||||
<div className="flex gap-8"><span>DESIGNED BY ANTIGRAVITY</span></div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
noteId={note.id}
|
||||
onOpenNote={(noteId: string) => {
|
||||
onClose()
|
||||
window.location.href = `/?note=${noteId}`
|
||||
window.location.href = `/home?note=${noteId}`
|
||||
}}
|
||||
onCompareNotes={(noteIds: string[]) => {
|
||||
Promise.all(noteIds.map(async (id: string) => {
|
||||
@@ -296,7 +296,7 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
notes={state.comparisonNotes}
|
||||
onOpenNote={(noteId: string) => {
|
||||
onClose()
|
||||
window.location.href = `/?note=${noteId}`
|
||||
window.location.href = `/home?note=${noteId}`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -177,23 +177,23 @@ export function NotificationPanel() {
|
||||
|
||||
// ── icon bg/color per notification type ──────────────────────────────────
|
||||
const notifIconStyle = (type: string) => {
|
||||
if (type === 'agent_success') return { bg: `${C.gold}20`, color: C.gold }
|
||||
if (type === 'agent_slides_ready') return { bg: `${C.gold}20`, color: C.gold }
|
||||
if (type === 'agent_canvas_ready') return { bg: `${C.gold}20`, color: C.gold }
|
||||
if (type === 'agent_failure') return { bg: '#EF444420', color: '#EF4444' }
|
||||
if (type === 'brainstorm_invite') return { bg: '#10b98120', color: '#10b981' }
|
||||
if (type === 'brainstorm_joined') return { bg: '#60a5fa20', color: '#60a5fa' }
|
||||
return { bg: `${C.green}20`, color: C.green }
|
||||
if (type === 'agent_success') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
||||
if (type === 'agent_slides_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
||||
if (type === 'agent_canvas_ready') return { bg: 'rgba(164,113,72,0.12)', color: '#A47148' }
|
||||
if (type === 'agent_failure') return { bg: 'rgba(239,68,68,0.12)', color: '#EF4444' }
|
||||
if (type === 'brainstorm_invite') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||
if (type === 'brainstorm_joined') return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||
return { bg: 'rgba(163,177,138,0.12)', color: '#A3B18A' }
|
||||
}
|
||||
|
||||
const notifLabelColor = (type: string) => {
|
||||
if (type.startsWith('agent')) {
|
||||
if (type === 'agent_failure') return '#EF4444'
|
||||
if (type === 'brainstorm_invite') return '#10b981'
|
||||
if (type === 'brainstorm_joined') return '#60a5fa'
|
||||
return C.gold
|
||||
if (type === 'brainstorm_invite') return '#A3B18A'
|
||||
if (type === 'brainstorm_joined') return '#A3B18A'
|
||||
return '#A47148'
|
||||
}
|
||||
return C.green
|
||||
return '#A3B18A'
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -205,8 +205,7 @@ export function NotificationPanel() {
|
||||
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm"
|
||||
style={{ background: C.green }}
|
||||
className="absolute -top-1 -right-1 h-4 w-4 flex items-center justify-center rounded-full text-white text-[9px] font-bold border border-white shadow-sm bg-brand-accent"
|
||||
>
|
||||
{pendingCount > 9 ? '9+' : pendingCount}
|
||||
</span>
|
||||
@@ -235,8 +234,7 @@ export function NotificationPanel() {
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<span
|
||||
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold"
|
||||
style={{ background: C.green }}
|
||||
className="h-5 px-1.5 flex items-center justify-center rounded-full text-white text-[9px] font-bold bg-brand-accent"
|
||||
>
|
||||
{pendingCount}
|
||||
</span>
|
||||
@@ -405,14 +403,14 @@ export function NotificationPanel() {
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="h-8 w-8 rounded-full flex items-center justify-center text-white font-bold text-[11px] shrink-0 shadow-sm"
|
||||
style={{ background: `linear-gradient(135deg, #fb923c, #f97316)` }}
|
||||
style={{ background: `linear-gradient(135deg, #A47148, #A47148)` }}
|
||||
>
|
||||
{(share.sharer?.name || share.sharer?.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<Wind className="w-3 h-3" style={{ color: '#fb923c' }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#fb923c' }}>
|
||||
<Wind className="w-3 h-3" style={{ color: '#A47148' }} />
|
||||
<span className="text-[9px] font-bold uppercase tracking-[0.2em]" style={{ color: '#A47148' }}>
|
||||
Brainstorm
|
||||
</span>
|
||||
</div>
|
||||
@@ -434,8 +432,7 @@ export function NotificationPanel() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAcceptBrainstorm(share.id)}
|
||||
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90"
|
||||
style={{ background: '#fb923c' }}
|
||||
className="flex-1 h-7 px-3 text-[11px] font-bold rounded-lg text-white transition-all active:scale-95 flex items-center justify-center gap-1 shadow-sm hover:opacity-90 bg-brand-accent"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t('notification.accept') || 'Accept'}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
export function PageEntry({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
'use client'
|
||||
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
memento-note/components/public-providers.tsx
Normal file
41
memento-note/components/public-providers.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { QueryProvider } from '@/components/query-provider'
|
||||
import type { Translations } from '@/lib/i18n/load-translations'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const RTL_LANGUAGES = ['ar', 'fa']
|
||||
|
||||
function DirWrapper({ children }: { children: ReactNode }) {
|
||||
const { language } = useLanguage()
|
||||
const dir = RTL_LANGUAGES.includes(language) ? 'rtl' : 'ltr'
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = dir
|
||||
}, [language, dir])
|
||||
|
||||
return <div dir={dir} className="contents">{children}</div>
|
||||
}
|
||||
|
||||
export function PublicProviders({
|
||||
children,
|
||||
initialLanguage = 'en',
|
||||
initialTranslations
|
||||
}: {
|
||||
children: ReactNode
|
||||
initialLanguage?: string
|
||||
initialTranslations?: Translations
|
||||
}) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
||||
<DirWrapper>
|
||||
{children}
|
||||
</DirWrapper>
|
||||
</LanguageProvider>
|
||||
</QueryProvider>
|
||||
)
|
||||
}
|
||||
@@ -150,7 +150,22 @@ async function aiReformulate(text: string, option: string, language?: string): P
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
const serverMsg = typeof data?.error === 'string' && data.error.trim() ? data.error.trim() : AI_REFORMULATE_FALLBACK
|
||||
if (data?.errorKey === 'ai.wordCountMin') {
|
||||
throw new Error(t('ai.wordCountMin') || `Minimum ${data?.params?.min || 10} mots requis (${data?.params?.current || 0} actuels)`)
|
||||
}
|
||||
if (data?.errorKey === 'ai.wordCountMax') {
|
||||
throw new Error(t('ai.wordCountMax') || `Maximum ${data?.params?.max || 500} mots (${data?.params?.current || 0} actuels)`)
|
||||
}
|
||||
if (data?.errorKey === 'ai.featureLocked') {
|
||||
throw new Error(t('ai.featureLocked') || 'Cette fonctionnalité nécessite le plan PRO.')
|
||||
}
|
||||
if (data?.errorKey === 'ai.quotaExceeded') {
|
||||
throw new Error(t('ai.quotaExceeded') || 'Limite mensuelle atteinte.')
|
||||
}
|
||||
if (data?.quotaExceeded) {
|
||||
throw new Error(t('ai.quotaExceeded') || 'Limite mensuelle atteinte.')
|
||||
}
|
||||
const serverMsg = typeof data?.error === 'string' && !data.error.includes(':') ? data.error.trim() : AI_REFORMULATE_FALLBACK
|
||||
throw new Error(serverMsg)
|
||||
}
|
||||
return data.reformulatedText || data.text || text
|
||||
@@ -385,6 +400,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||
try {
|
||||
const lang = option === 'translate' ? (targetLang || language) : language
|
||||
const result = await aiReformulate(text, option, lang)
|
||||
window.dispatchEvent(new Event('ai-usage-changed'))
|
||||
if (option === 'explain') {
|
||||
setAiModal({ type: 'explain', origText: text, html: result, from, to })
|
||||
} else {
|
||||
|
||||
@@ -28,12 +28,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<nav className={`flex items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
|
||||
<nav className={`flex flex-wrap items-center gap-1 border-b border-border/40 pb-px ${className || ''}`}>
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className="flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
|
||||
className="flex items-center gap-2.5 px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
|
||||
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
|
||||
>
|
||||
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>
|
||||
|
||||
@@ -234,7 +234,7 @@ export function BillingPlans() {
|
||||
|
||||
{/* Usage Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="md:col-span-2 bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-brand-accent/10 text-brand-accent rounded-xl">
|
||||
<Activity size={20} />
|
||||
@@ -243,18 +243,57 @@ export function BillingPlans() {
|
||||
<h4 className="text-sm font-bold text-ink">{t('billing.currentUsage') || 'Utilisation actuelle'}</h4>
|
||||
<p className="text-[10px] text-concrete uppercase tracking-widest">{t('billing.currentPeriod') || 'Période en cours'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-[11px] font-medium text-concrete uppercase tracking-wider">
|
||||
<span>{t('billing.aiCredits') || 'Crédits IA'}</span>
|
||||
<span>{aiUsed} / {aiLimit} {t('billing.used') || 'utilisés'}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-brand-accent rounded-full" style={{ width: `${aiPct}%` }} />
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<span className={cn(
|
||||
'px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest',
|
||||
isPaid ? 'bg-brand-accent/10 text-brand-accent' : 'bg-concrete/10 text-concrete'
|
||||
)}>
|
||||
{effectiveTier === 'BASIC' ? 'Discovery' : effectiveTier}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{!quotas && (
|
||||
<div className="col-span-full flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-concrete" />
|
||||
</div>
|
||||
)}
|
||||
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).length === 0 && (
|
||||
<p className="col-span-full text-xs text-concrete text-center py-4">Aucune donnée d'utilisation disponible</p>
|
||||
)}
|
||||
{quotas && Object.entries(quotas).filter(([_, q]) => q.limit > 0).map(([key, q]) => {
|
||||
const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch'),
|
||||
auto_tag: t('usageMeter.featureTags'),
|
||||
auto_title: t('usageMeter.featureTitles'),
|
||||
reformulate: t('usageMeter.featureReformulate'),
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate'),
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||
}
|
||||
return (
|
||||
<div key={key} className="space-y-2 p-3 rounded-xl bg-paper/50 dark:bg-white/5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[10px] font-bold text-concrete uppercase tracking-wider truncate">
|
||||
{featureLabels[key] || key}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-bold text-ink">{q.remaining === Infinity ? '∞' : q.remaining}</span>
|
||||
<span className="text-[10px] text-concrete">/ {q.limit === Infinity ? '∞' : q.limit}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-slate-100 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', pct >= 90 ? 'bg-rose-500' : pct >= 70 ? 'bg-amber-500' : 'bg-brand-accent')}
|
||||
style={{ width: `${Math.min(pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-3xl p-8 space-y-6">
|
||||
|
||||
@@ -469,7 +469,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const currentNoteId = searchParams.get('openNote')
|
||||
|
||||
const isInboxActive =
|
||||
pathname === '/' &&
|
||||
pathname === '/home' &&
|
||||
!searchParams.get('notebook') &&
|
||||
!searchParams.get('labels') &&
|
||||
!searchParams.get('archived') &&
|
||||
@@ -534,11 +534,11 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const params = new URLSearchParams()
|
||||
params.set('notebook', notebookId)
|
||||
params.set('forceList', '1')
|
||||
router.push(`/?${params.toString()}`)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
|
||||
const handleInboxClick = () => {
|
||||
router.push('/?forceList=1')
|
||||
router.push('/home?forceList=1')
|
||||
}
|
||||
|
||||
const handleNoteClick = (noteId: string, notebookId: string) => {
|
||||
@@ -546,7 +546,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
params.set('notebook', notebookId)
|
||||
params.set('openNote', noteId)
|
||||
params.delete('forceList')
|
||||
router.push(`/?${params.toString()}`)
|
||||
router.push(`/home?${params.toString()}`)
|
||||
}
|
||||
|
||||
// ── Drag state ──
|
||||
@@ -706,7 +706,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
await trashNotebook(deletingNotebook.id)
|
||||
setDeletingNotebook(null)
|
||||
if (currentNotebookId === deletingNotebook.id) {
|
||||
router.push('/')
|
||||
router.push('/home')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Trash failed:', err)
|
||||
@@ -871,7 +871,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<Settings size={14} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { router.push('/') }}
|
||||
onClick={() => { router.push('/home') }}
|
||||
className="p-1.5 text-muted-ink hover:text-ink transition-all hover:bg-white/50 dark:hover:bg-white/10 rounded-lg border border-transparent hover:border-border"
|
||||
title={t('nav.home') || 'Accueil'}
|
||||
>
|
||||
@@ -889,29 +889,29 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-xl border border-border dark:border-white/10">
|
||||
<button
|
||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/home') router.push('/home') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'notebooks' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('nav.notebooks')}
|
||||
>
|
||||
<BookOpen size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('reminders')}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'reminders' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('sidebar.reminders')}
|
||||
>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('agents'); router.push('/agents') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'agents' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('nav.agents')}
|
||||
>
|
||||
<Bot size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveView('brainstorms'); router.push('/brainstorm') }}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
className={cn('flex-1 flex items-center justify-center py-1.5 rounded-lg transition-all', activeView === 'brainstorms' ? 'bg-brand-accent text-white shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-white/50')}
|
||||
title={t('brainstorm.sessions')}
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
@@ -989,8 +989,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||
isInboxActive
|
||||
? 'bg-ink text-paper border-ink'
|
||||
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-paper dark:bg-white/5 text-muted-ink border-border group-hover:border-brand-accent/20'
|
||||
)}>
|
||||
<Inbox size={14} />
|
||||
</div>
|
||||
@@ -1067,8 +1067,8 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
||||
isActive
|
||||
? 'bg-foreground text-background border-foreground'
|
||||
: 'bg-paper border-border group-hover:border-foreground/20'
|
||||
? 'bg-brand-accent text-white border-brand-accent'
|
||||
: 'bg-paper border-border group-hover:border-brand-accent/20'
|
||||
)}>
|
||||
<item.icon size={16} />
|
||||
</div>
|
||||
@@ -1109,15 +1109,15 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<UsageMeter />
|
||||
<div className="px-2 space-y-0.5">
|
||||
<Link
|
||||
href="/?shared=1&forceList=1"
|
||||
href="/home?shared=1&forceList=1"
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||
searchParams.get('shared') === '1' && pathname === '/'
|
||||
searchParams.get('shared') === '1' && pathname === '/home'
|
||||
? 'bg-accent/5 text-accent'
|
||||
: 'text-muted-ink hover:text-ink hover:bg-black/5'
|
||||
)}
|
||||
>
|
||||
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/home' ? 'text-accent' : 'text-muted-ink group-hover:text-ink'} />
|
||||
<span>{t('sidebar.sharedWithMe') || 'Shared'}</span>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sparkles, X, Crown } from 'lucide-react';
|
||||
import { Sparkles, ChevronDown, X, Crown } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface QuotaData {
|
||||
@@ -17,27 +18,31 @@ interface UsageMeterProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatRemaining(value: number): string {
|
||||
if (!Number.isFinite(value)) return '∞';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function UsageMeter({ className }: UsageMeterProps) {
|
||||
const { t } = useLanguage();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/usage/current');
|
||||
if (!res.ok) throw new Error('Failed to fetch quotas');
|
||||
const data = await res.json();
|
||||
return data.quotas as Record<string, QuotaData>;
|
||||
const json = await res.json();
|
||||
return { quotas: json.quotas as Record<string, QuotaData>, tier: json.tier as string };
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
staleTime: 5000,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
useEffect(() => {
|
||||
const handler = () => queryClient.invalidateQueries({ queryKey: ['usage', 'current'] });
|
||||
window.addEventListener('ai-usage-changed', handler);
|
||||
return () => window.removeEventListener('ai-usage-changed', handler);
|
||||
}, [queryClient]);
|
||||
|
||||
if (isLoading || !data || !data.quotas) {
|
||||
return (
|
||||
<div className={cn('px-2 py-2', className)}>
|
||||
<div className="h-8 rounded-lg bg-paper/50 animate-pulse" />
|
||||
@@ -45,97 +50,151 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const features = [
|
||||
{ key: 'semantic_search', labelKey: 'usageMeter.featureSearch' as const },
|
||||
{ key: 'auto_tag', labelKey: 'usageMeter.featureTags' as const },
|
||||
{ key: 'auto_title', labelKey: 'usageMeter.featureTitles' as const },
|
||||
] as const;
|
||||
const isProPlus = data.tier && data.tier !== 'BASIC';
|
||||
|
||||
const featureQuotas = features.map((f) => {
|
||||
const quota = data[f.key];
|
||||
return {
|
||||
...f,
|
||||
label: t(f.labelKey),
|
||||
used: quota?.used ?? 0,
|
||||
limit: quota?.limit ?? 0,
|
||||
remaining: quota?.remaining ?? 0,
|
||||
};
|
||||
});
|
||||
const featureLabels: Record<string, string> = {
|
||||
semantic_search: t('usageMeter.featureSearch'),
|
||||
auto_tag: t('usageMeter.featureTags'),
|
||||
auto_title: t('usageMeter.featureTitles'),
|
||||
reformulate: t('usageMeter.featureReformulate'),
|
||||
chat: t('usageMeter.featureChat'),
|
||||
brainstorm_create: t('usageMeter.featureBrainstormCreate'),
|
||||
brainstorm_expand: t('usageMeter.featureBrainstormExpand'),
|
||||
brainstorm_enrich: t('usageMeter.featureBrainstormEnrich'),
|
||||
};
|
||||
|
||||
const featureQuotas = Object.entries(data.quotas)
|
||||
.filter(([_, q]) => q.limit > 0)
|
||||
.map(([key, quota]) => ({
|
||||
key,
|
||||
label: featureLabels[key] || key,
|
||||
used: quota.used,
|
||||
limit: quota.limit,
|
||||
remaining: quota.remaining,
|
||||
}));
|
||||
|
||||
const isUnlimited = featureQuotas.every((f) => !Number.isFinite(f.limit));
|
||||
|
||||
const totalRemaining = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.remaining) ? f.remaining : 0),
|
||||
0,
|
||||
const totalUsed = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.used) ? f.used : 0), 0,
|
||||
);
|
||||
const totalLimit = featureQuotas.reduce(
|
||||
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0),
|
||||
0,
|
||||
(sum, f) => sum + (Number.isFinite(f.limit) ? f.limit : 0), 0,
|
||||
);
|
||||
|
||||
const used = totalLimit - totalRemaining;
|
||||
const percentage = totalLimit > 0 ? Math.min((used / totalLimit) * 100, 100) : 0;
|
||||
const totalRemaining = totalLimit - totalUsed;
|
||||
const totalPct = totalLimit > 0 ? (totalUsed / totalLimit) * 100 : 0;
|
||||
const isExhausted = !isUnlimited && totalRemaining <= 0;
|
||||
const isLow = !isUnlimited && percentage >= 75 && !isExhausted;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 py-2 w-full">
|
||||
<div className="p-4 bg-slate-50 dark:bg-white/5 border border-border rounded-2xl space-y-3 group hover:shadow-lg transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={
|
||||
isExhausted
|
||||
? 'text-rose-400 fill-rose-400/20'
|
||||
: isUnlimited
|
||||
? 'text-emerald-400 fill-emerald-400/20'
|
||||
: 'text-brand-accent fill-brand-accent/20'
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
isExhausted
|
||||
? 'text-rose-500'
|
||||
: isUnlimited
|
||||
? 'text-emerald-500'
|
||||
: isLow
|
||||
? 'text-amber-500'
|
||||
: 'text-concrete',
|
||||
)}
|
||||
>
|
||||
{isUnlimited
|
||||
? t('usageMeter.unlimited')
|
||||
: t('usageMeter.remaining', { count: totalRemaining })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
isExhausted
|
||||
? 'bg-rose-400'
|
||||
: isLow
|
||||
? 'bg-amber-400'
|
||||
: 'bg-brand-accent',
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-50 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => router.push('/settings/billing')}
|
||||
className="w-full py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full p-3 flex items-center gap-2 hover:bg-slate-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{t('usageMeter.upgradePricing') || 'Passer à Pro'}
|
||||
<Sparkles
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
isExhausted
|
||||
? 'text-rose-400 fill-rose-400/20'
|
||||
: isUnlimited
|
||||
? 'text-emerald-400 fill-emerald-400/20'
|
||||
: 'text-brand-accent fill-brand-accent/20'
|
||||
)}
|
||||
/>
|
||||
<span className="text-[11px] font-bold text-ink/70">{t('usageMeter.packName')}</span>
|
||||
|
||||
{!isUnlimited && (
|
||||
<>
|
||||
<div className="flex-1 h-1 bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden mx-2">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
totalPct >= 90 ? 'bg-rose-400' : totalPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
||||
)}
|
||||
style={{ width: `${Math.min(totalPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-[10px] font-medium tabular-nums shrink-0',
|
||||
totalPct >= 90 ? 'text-rose-500' : totalPct >= 70 ? 'text-amber-500' : 'text-concrete'
|
||||
)}>
|
||||
{totalRemaining}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isUnlimited && (
|
||||
<span className="text-[10px] font-medium text-emerald-500 ml-auto">{t('usageMeter.unlimited')}</span>
|
||||
)}
|
||||
|
||||
{isProPlus && !isUnlimited && (
|
||||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">{data.tier}</span>
|
||||
)}
|
||||
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
'text-concrete shrink-0 transition-transform duration-200',
|
||||
expanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<div className="border-t border-border/40 pt-2" />
|
||||
{featureQuotas.map((f) => {
|
||||
const fPct = Number.isFinite(f.limit) && f.limit > 0 ? (f.used / f.limit) * 100 : 0
|
||||
return (
|
||||
<div key={f.key} className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-concrete truncate">{f.label}</span>
|
||||
<span className={cn(
|
||||
'font-medium tabular-nums',
|
||||
fPct >= 90 ? 'text-rose-500' : fPct >= 70 ? 'text-amber-500' : 'text-ink/60'
|
||||
)}>
|
||||
{!Number.isFinite(f.remaining) ? '∞' : f.remaining}/{!Number.isFinite(f.limit) ? '∞' : f.limit}
|
||||
</span>
|
||||
</div>
|
||||
{Number.isFinite(f.limit) && (
|
||||
<div className="h-1 w-full bg-slate-200 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
fPct >= 90 ? 'bg-rose-400' : fPct >= 70 ? 'bg-amber-400' : 'bg-brand-accent',
|
||||
)}
|
||||
style={{ width: `${Math.min(fPct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{!isProPlus && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); router.push('/settings/billing'); }}
|
||||
className="w-full mt-2 py-2 bg-brand-accent/10 hover:bg-brand-accent text-brand-accent hover:text-white text-[9px] font-bold uppercase tracking-widest rounded-xl transition-all duration-300 border border-brand-accent/20"
|
||||
>
|
||||
{t('usageMeter.upgradePricing') || 'Passer à Pro'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user