feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View File

@@ -59,6 +59,26 @@ type SlashItem = {
command: (editor: Editor, range?: any) => void
}
type SlashCategoryId = 'basic' | 'media' | 'formatting' | 'ai'
type SlashMenuItem = SlashItem & { categoryId: SlashCategoryId }
const ORDERED_SLASH_CATEGORIES: SlashCategoryId[] = ['basic', 'media', 'formatting', 'ai']
function slashCategoryLabel(id: SlashCategoryId, t: (key: string) => string): string {
switch (id) {
case 'basic': return t('richTextEditor.slashCatBasic')
case 'media': return t('richTextEditor.slashCatMedia')
case 'formatting': return t('richTextEditor.slashCatFormatting')
case 'ai': return t('richTextEditor.slashCatAi')
}
}
/** Sent to /api/ai/reformulate as target language (unchanged for prompt compatibility). */
const TRANSLATE_TARGET_API_VALUES = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais'] as const
const AI_REFORMULATE_FALLBACK = '__RICH_TEXT_AI_FALLBACK__'
const CustomImage = Image.extend({
addAttributes() {
return {
@@ -129,7 +149,10 @@ async function aiReformulate(text: string, option: string, language?: string): P
body: JSON.stringify({ text, option, format: 'html', language }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'AI failed')
if (!res.ok) {
const serverMsg = typeof data?.error === 'string' && data.error.trim() ? data.error.trim() : AI_REFORMULATE_FALLBACK
throw new Error(serverMsg)
}
return data.reformulatedText || data.text || text
}
@@ -286,7 +309,7 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
value={url}
onChange={(e) => { setUrl(e.target.value); setError(''); setPreview(null) }}
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); if (e.key === 'Escape') onCancel() }}
placeholder="https://example.com/image.png"
placeholder={t('richTextEditor.imageUrlPlaceholder')}
className="notion-modal-input"
/>
{url.trim() && !preview && (
@@ -309,10 +332,14 @@ function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void;
)
}
const AI_LANGS = ['Francais', 'English', 'Espanol', 'Deutsch', 'Persan', 'Portugais', 'Italiano', 'Chinois', 'Japonais']
function BubbleToolbar({ editor }: { editor: Editor | null }) {
const { t, language } = useLanguage()
const toastAiError = (err: unknown) => {
const msg = err instanceof Error ? err.message : ''
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
}
const [, setTick] = useState(0)
const [aiOpen, setAiOpen] = useState(false)
const [aiLoading, setAiLoading] = useState(false)
@@ -365,6 +392,7 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
}
} catch (err) {
console.error('AI error:', err)
toastAiError(err)
} finally {
setAiLoading(false)
}
@@ -444,8 +472,10 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
<button className="notion-ai-subitem" onClick={() => setTranslateOpen(v => !v)}><Languages className="w-3.5 h-3.5 text-indigo-500" /><span>{t('ai.action.translate')}</span></button>
{translateOpen && (
<div className="notion-ai-lang-picker">
{AI_LANGS.map(l => (
<button key={l} className="notion-ai-lang-item" onClick={() => handleAI('translate', l)}>{l}</button>
{TRANSLATE_TARGET_API_VALUES.map((apiValue) => (
<button key={apiValue} className="notion-ai-lang-item" onClick={() => handleAI('translate', apiValue)}>
{t(`richTextEditor.translateTargets.${apiValue}`)}
</button>
))}
<div className="flex items-center gap-1 px-1 pt-1 w-full border-t border-border/40 mt-1">
<input
@@ -498,49 +528,42 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [activeCategory, setActiveCategory] = useState<SlashCategoryId | null>(null)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const [aiLoading, setAiLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null)
const menuInteracting = useRef(false)
const CAT_LABELS: Record<string, string> = {
'Basic blocks': t('richTextEditor.slashCatBasic'),
'Media': t('richTextEditor.slashCatMedia'),
'Formatting': t('richTextEditor.slashCatFormatting'),
'IA Note': t('richTextEditor.slashCatAi'),
}
const localCommands: SlashItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), category: t('richTextEditor.slashCatBasic') },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), category: t('richTextEditor.slashCatMedia') },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), category: t('richTextEditor.slashCatFormatting') },
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), category: t('richTextEditor.slashCatAi') },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), category: t('richTextEditor.slashCatAi') },
const localCommands: SlashMenuItem[] = [
{ ...slashCommands[0], title: t('richTextEditor.slashText'), description: t('richTextEditor.slashTextDesc'), categoryId: 'basic' },
{ ...slashCommands[1], title: t('richTextEditor.slashH1'), description: t('richTextEditor.slashH1Desc'), categoryId: 'basic' },
{ ...slashCommands[2], title: t('richTextEditor.slashH2'), description: t('richTextEditor.slashH2Desc'), categoryId: 'basic' },
{ ...slashCommands[3], title: t('richTextEditor.slashH3'), description: t('richTextEditor.slashH3Desc'), categoryId: 'basic' },
{ ...slashCommands[4], title: t('richTextEditor.slashTable'), description: t('richTextEditor.slashTableDesc'), categoryId: 'basic' },
{ ...slashCommands[5], title: t('richTextEditor.slashBullet'), description: t('richTextEditor.slashBulletDesc'), categoryId: 'basic' },
{ ...slashCommands[6], title: t('richTextEditor.slashNumbered'), description: t('richTextEditor.slashNumberedDesc'), categoryId: 'basic' },
{ ...slashCommands[7], title: t('richTextEditor.slashTodo'), description: t('richTextEditor.slashTodoDesc'), categoryId: 'basic' },
{ ...slashCommands[8], title: t('richTextEditor.slashQuote'), description: t('richTextEditor.slashQuoteDesc'), categoryId: 'basic' },
{ ...slashCommands[9], title: t('richTextEditor.slashCode'), description: t('richTextEditor.slashCodeDesc'), categoryId: 'basic' },
{ ...slashCommands[10], title: t('richTextEditor.slashDivider'), description: t('richTextEditor.slashDividerDesc'), categoryId: 'basic' },
{ ...slashCommands[11], title: t('richTextEditor.slashImage'), description: t('richTextEditor.slashImageDesc'), categoryId: 'media' },
{ ...slashCommands[12], title: t('richTextEditor.slashAlignLeft'), description: t('richTextEditor.slashAlignLeftDesc'), categoryId: 'formatting' },
{ ...slashCommands[13], title: t('richTextEditor.slashAlignCenter'), description: t('richTextEditor.slashAlignCenterDesc'), categoryId: 'formatting' },
{ ...slashCommands[14], title: t('richTextEditor.slashAlignRight'), description: t('richTextEditor.slashAlignRightDesc'), categoryId: 'formatting' },
{ ...slashCommands[15], title: t('richTextEditor.slashClarify'), description: t('richTextEditor.slashClarifyDesc'), categoryId: 'ai' },
{ ...slashCommands[16], title: t('richTextEditor.slashShorten'), description: t('richTextEditor.slashShortenDesc'), categoryId: 'ai' },
{ ...slashCommands[17], title: t('richTextEditor.slashImprove'), description: t('richTextEditor.slashImproveDesc'), categoryId: 'ai' },
{ ...slashCommands[18], title: t('richTextEditor.slashExpand'), description: t('richTextEditor.slashExpandDesc'), categoryId: 'ai' },
{ ...slashCommands[19], title: t('richTextEditor.bold'), description: t('richTextEditor.bold'), categoryId: 'formatting' },
{ ...slashCommands[20], title: t('richTextEditor.italic'), description: t('richTextEditor.italic'), categoryId: 'formatting' },
{ ...slashCommands[21], title: t('richTextEditor.underline'), description: t('richTextEditor.underline'), categoryId: 'formatting' },
{ ...slashCommands[22], title: t('richTextEditor.strike'), description: t('richTextEditor.strike'), categoryId: 'formatting' },
{ ...slashCommands[23], title: t('richTextEditor.highlight'), description: t('richTextEditor.highlight'), categoryId: 'formatting' },
{ ...slashCommands[24], title: t('richTextEditor.slashSuperscript'), description: t('richTextEditor.slashSuperscriptDesc'), categoryId: 'formatting' },
{ ...slashCommands[25], title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'formatting' },
{ ...slashCommands[26], title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
{ ...slashCommands[27], title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
]
const closeMenu = useCallback(() => {
@@ -557,7 +580,11 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
}
}, [editor])
const handleSelect = useCallback(async (item: SlashItem) => {
const handleSelect = useCallback(async (item: SlashMenuItem) => {
const toastAi = (err: unknown) => {
const msg = err instanceof Error ? err.message : ''
toast.error(msg && msg !== AI_REFORMULATE_FALLBACK ? msg : t('richTextEditor.aiReformulateFailed'))
}
if (item.isImage) {
deleteSlashText(); closeMenu(); onInsertImage(editor)
} else if (item.isAi && item.aiOption) {
@@ -567,31 +594,35 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (!allText || allText.split(/\s+/).length < 5) return
const result = await aiReformulate(allText, item.aiOption)
editor.chain().focus().setContent(result).run()
} catch (err) { console.error('AI slash error:', err) }
} catch (err) {
console.error('AI slash error:', err)
toastAi(err)
}
finally { setAiLoading(false) }
} else {
deleteSlashText(); item.command(editor); closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage])
}, [editor, closeMenu, deleteSlashText, onInsertImage, t])
const allCategories = Array.from(new Set(localCommands.map(c => c.category || 'Basic blocks')))
const presentCategoryIds = new Set(localCommands.map(c => c.categoryId))
const allCategories = ORDERED_SLASH_CATEGORIES.filter(id => presentCategoryIds.has(id))
const textFiltered = localCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()) || c.description.toLowerCase().includes(query.toLowerCase()))
const filtered = activeCategory ? textFiltered.filter(c => (c.category || 'Basic blocks') === activeCategory) : textFiltered
const filtered = activeCategory ? textFiltered.filter(c => c.categoryId === activeCategory) : textFiltered
const availableCategoriesInSearch = textFiltered.reduce((acc, item) => {
const cat = item.category || 'Basic blocks'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
const id = item.categoryId
if (!acc[id]) acc[id] = []
acc[id].push(item)
return acc
}, {} as Record<string, SlashItem[]>)
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
const categories = filtered.reduce((acc, item) => {
const cat = item.category || 'Basic blocks'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
const id = item.categoryId
if (!acc[id]) acc[id] = []
acc[id].push(item)
return acc
}, {} as Record<string, SlashItem[]>)
}, {} as Record<SlashCategoryId, SlashMenuItem[]>)
// Auto-scroll selected item into view
useEffect(() => {
@@ -605,7 +636,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
e.preventDefault()
const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])]
const availableTabs = [null as SlashCategoryId | null, ...allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0)]
const currentIndex = availableTabs.indexOf(activeCategory)
const nextIndex = e.key === 'ArrowRight'
? (currentIndex + 1) % availableTabs.length
@@ -623,7 +654,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
if (e.key === 'Tab') {
e.preventDefault()
const availableTabs = [null, ...allCategories.filter(cat => availableCategoriesInSearch[cat])]
const availableTabs = [null as SlashCategoryId | null, ...allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0)]
const nextIndex = (availableTabs.indexOf(activeCategory) + 1) % availableTabs.length
setActiveCategory(availableTabs[nextIndex])
setSelectedIndex(0)
@@ -632,7 +663,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, categories, activeCategory])
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu, activeCategory, allCategories, availableCategoriesInSearch])
useEffect(() => {
if (!isOpen) return
@@ -683,7 +714,8 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
if (!isOpen || filtered.length === 0) return null
let flatIndex = -1
const totalVisible = Object.keys(categories).length
const sectionIds = ORDERED_SLASH_CATEGORIES.filter(id => (categories[id]?.length ?? 0) > 0)
const totalVisible = sectionIds.length
return createPortal(
<div
@@ -702,16 +734,16 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
>
{t('richTextEditor.slashTabAll')}
</button>
{allCategories.filter(cat => availableCategoriesInSearch[cat]).map(cat => (
{allCategories.filter(id => (availableCategoriesInSearch[id]?.length ?? 0) > 0).map(catId => (
<button
key={cat}
className={cn('notion-slash-tab', activeCategory === cat && 'notion-slash-tab-active', cat === 'IA Note' && 'notion-slash-tab-ai')}
key={catId}
className={cn('notion-slash-tab', activeCategory === catId && 'notion-slash-tab-active', catId === 'ai' && 'notion-slash-tab-ai')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => { setActiveCategory(activeCategory === cat ? null : cat); setSelectedIndex(0) }}
onClick={() => { setActiveCategory(activeCategory === catId ? null : catId); setSelectedIndex(0) }}
>
{cat === 'IA Note' && <Sparkles className="w-2.5 h-2.5" />}
{cat}
<span className="notion-slash-tab-count">{availableCategoriesInSearch[cat]?.length ?? 0}</span>
{catId === 'ai' && <Sparkles className="w-2.5 h-2.5" />}
{slashCategoryLabel(catId, t)}
<span className="notion-slash-tab-count">{availableCategoriesInSearch[catId]?.length ?? 0}</span>
</button>
))}
</div>
@@ -728,40 +760,43 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
<span>{t('richTextEditor.slashLoading')}</span>
</div>
)}
{!aiLoading && Object.entries(categories).map(([cat, items]) => (
<div key={cat} className="notion-slash-section">
<div className={cn('notion-slash-label', cat === 'IA Note' && 'notion-slash-label-ai')}>
{cat === 'IA Note' && <Sparkles className="w-3 h-3 inline mr-1" />}
{CAT_LABELS[cat] || cat}
{!aiLoading && sectionIds.map((catId) => {
const items = categories[catId]!
return (
<div key={catId} className="notion-slash-section">
<div className={cn('notion-slash-label', catId === 'ai' && 'notion-slash-label-ai')}>
{catId === 'ai' && <Sparkles className="w-3 h-3 inline mr-1" />}
{slashCategoryLabel(catId, t)}
</div>
{items.map((item) => {
flatIndex++
const idx = flatIndex
const isSelected = idx === selectedIndex
return (
<button
key={`${catId}-${item.title}-${item.shortcut ?? ''}`}
ref={isSelected ? selectedItemRef : null}
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
<item.icon className="w-4 h-4" />
</div>
<div className="notion-slash-content">
<span className="notion-slash-title">{item.title}</span>
<span className="notion-slash-desc">{item.description}</span>
</div>
{item.shortcut && (
<span className="notion-slash-shortcut">{item.shortcut}</span>
)}
</button>
)
})}
</div>
{items.map((item) => {
flatIndex++
const idx = flatIndex
const isSelected = idx === selectedIndex
return (
<button
key={item.title}
ref={isSelected ? selectedItemRef : null}
className={cn('notion-slash-item', isSelected && 'notion-slash-item-selected')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
<item.icon className="w-4 h-4" />
</div>
<div className="notion-slash-content">
<span className="notion-slash-title">{item.title}</span>
<span className="notion-slash-desc">{item.description}</span>
</div>
{item.shortcut && (
<span className="notion-slash-shortcut">{item.shortcut}</span>
)}
</button>
)
})}
</div>
))}
)
})}
</div>,
document.body
)