Compare commits
8 Commits
d0387cd9a0
...
0f48df114a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f48df114a | |||
| af6e7a2cdf | |||
| 88ba0a561a | |||
| 82db722735 | |||
| afa8043fd5 | |||
| ad3af531b8 | |||
| bd4034777c | |||
| 54b7b4fcf1 |
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, option, format } = await request.json()
|
const { text, option, format, language } = await request.json()
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!text || typeof text !== 'string') {
|
if (!text || typeof text !== 'string') {
|
||||||
@@ -25,16 +25,19 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map option to refactor mode
|
// Map option to refactor mode
|
||||||
const modeMap: Record<string, 'clarify' | 'shorten' | 'improveStyle'> = {
|
const modeMap: Record<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar' | 'translate' | 'explain'> = {
|
||||||
'clarify': 'clarify',
|
'clarify': 'clarify',
|
||||||
'shorten': 'shorten',
|
'shorten': 'shorten',
|
||||||
'improve': 'improveStyle'
|
'improve': 'improveStyle',
|
||||||
|
'fix_grammar': 'fix_grammar',
|
||||||
|
'translate': 'translate',
|
||||||
|
'explain': 'explain'
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = modeMap[option]
|
const mode = modeMap[option]
|
||||||
if (!mode) {
|
if (!mode) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid option. Use: clarify, shorten, or improve' },
|
{ error: 'Invalid option. Use: clarify, shorten, improve, fix_grammar, translate, or explain' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -50,7 +53,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the ParagraphRefactorService
|
// Use the ParagraphRefactorService
|
||||||
const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown')
|
const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language)
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
originalText: result.original,
|
originalText: result.original,
|
||||||
@@ -66,3 +69,4 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1139,6 +1139,61 @@ html.font-system * {
|
|||||||
.notion-ai-subitem:hover {
|
.notion-ai-subitem:hover {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
.notion-ai-lang-picker {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.notion-ai-lang-item {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--foreground);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.notion-ai-lang-item:hover { background: var(--accent); }
|
||||||
|
.notion-ai-result-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
z-index: 99998;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.notion-ai-result-modal {
|
||||||
|
background: var(--popover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
|
||||||
|
padding: 20px;
|
||||||
|
width: min(520px, 90vw);
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.dark .notion-ai-result-modal { box-shadow: 0 20px 60px rgba(0,0,0,0.6); }
|
||||||
|
.notion-ai-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.notion-ai-result-section { margin-bottom: 12px; }
|
||||||
|
.notion-ai-result-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Inline input inside bubble menu (link editor) */
|
/* Inline input inside bubble menu (link editor) */
|
||||||
.notion-inline-input {
|
.notion-inline-input {
|
||||||
@@ -1366,6 +1421,9 @@ html.font-system * {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
transition: background 0.1s ease, transform 0.08s ease;
|
transition: background 0.1s ease, transform 0.08s ease;
|
||||||
}
|
}
|
||||||
|
[dir="rtl"] .notion-slash-item {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
.notion-slash-item:hover,
|
.notion-slash-item:hover,
|
||||||
.notion-slash-item-selected {
|
.notion-slash-item-selected {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
|
|
||||||
<FeatureToggle
|
<FeatureToggle
|
||||||
name="IA Note"
|
name={t('aiSettings.aiNote')}
|
||||||
description="Active le bouton de chat IA et les outils d'amélioration du texte"
|
description={t('aiSettings.aiNoteDesc')}
|
||||||
checked={settings.paragraphRefactor}
|
checked={settings.paragraphRefactor}
|
||||||
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
|
||||||
/>
|
/>
|
||||||
@@ -167,37 +167,37 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
|
|
||||||
{/* Language Detection Toggle */}
|
{/* Language Detection Toggle */}
|
||||||
<FeatureToggle
|
<FeatureToggle
|
||||||
name="Détection de langue"
|
name={t('aiSettings.languageDetection')}
|
||||||
description="Détecte automatiquement la langue de vos notes"
|
description={t('aiSettings.languageDetectionDesc')}
|
||||||
checked={settings.languageDetection ?? true}
|
checked={settings.languageDetection ?? true}
|
||||||
onChange={(checked) => handleToggle('languageDetection', checked)}
|
onChange={(checked) => handleToggle('languageDetection', checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Auto Labeling Toggle */}
|
{/* Auto Labeling Toggle */}
|
||||||
<FeatureToggle
|
<FeatureToggle
|
||||||
name="Suggestion des labels"
|
name={t('aiSettings.autoLabeling')}
|
||||||
description="Suggère et applique des étiquettes automatiquement à vos notes"
|
description={t('aiSettings.autoLabelingDesc')}
|
||||||
checked={settings.autoLabeling ?? true}
|
checked={settings.autoLabeling ?? true}
|
||||||
onChange={(checked) => handleToggle('autoLabeling', checked)}
|
onChange={(checked) => handleToggle('autoLabeling', checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FeatureToggle
|
<FeatureToggle
|
||||||
name="Historique des notes"
|
name={t('aiSettings.noteHistory')}
|
||||||
description="Active les snapshots de versions et la restauration depuis History"
|
description={t('aiSettings.noteHistoryDesc')}
|
||||||
checked={settings.noteHistory ?? false}
|
checked={settings.noteHistory ?? false}
|
||||||
onChange={(checked) => handleToggle('noteHistory', checked)}
|
onChange={(checked) => handleToggle('noteHistory', checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{settings.noteHistory && (
|
{settings.noteHistory && (
|
||||||
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
|
<div className="space-y-2 rounded-lg border border-border/50 bg-muted/30 p-3">
|
||||||
<p className="text-sm font-medium">{t('notes.historyMode') || 'Mode d\'historique'}</p>
|
<p className="text-sm font-medium">{t('notes.historyMode')}</p>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={settings.noteHistoryMode ?? 'manual'}
|
value={settings.noteHistoryMode ?? 'manual'}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
const mode = value as 'manual' | 'auto'
|
const mode = value as 'manual' | 'auto'
|
||||||
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
|
setSettings((s) => ({ ...s, noteHistoryMode: mode }))
|
||||||
updateAISettings({ noteHistoryMode: mode }).then(() => {
|
updateAISettings({ noteHistoryMode: mode }).then(() => {
|
||||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
toast.success(t('settings.settingsSaved'))
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="space-y-2"
|
className="space-y-2"
|
||||||
@@ -206,10 +206,10 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
<RadioGroupItem value="manual" id="history-manual" />
|
<RadioGroupItem value="manual" id="history-manual" />
|
||||||
<div className="grid gap-0.5 leading-none">
|
<div className="grid gap-0.5 leading-none">
|
||||||
<Label htmlFor="history-manual" className="text-sm font-medium">
|
<Label htmlFor="history-manual" className="text-sm font-medium">
|
||||||
{t('notes.historyModeManual') || 'Manuel'}
|
{t('notes.historyModeManual')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('notes.historyModeManualDesc') || 'Créer des snapshots avec le bouton commit'}
|
{t('notes.historyModeManualDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,10 +217,10 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
|||||||
<RadioGroupItem value="auto" id="history-auto" />
|
<RadioGroupItem value="auto" id="history-auto" />
|
||||||
<div className="grid gap-0.5 leading-none">
|
<div className="grid gap-0.5 leading-none">
|
||||||
<Label htmlFor="history-auto" className="text-sm font-medium">
|
<Label htmlFor="history-auto" className="text-sm font-medium">
|
||||||
{t('notes.historyModeAuto') || 'Automatique'}
|
{t('notes.historyModeAuto')}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t('notes.historyModeAutoDesc') || 'Snapshots automatiques avec détection intelligente'}
|
{t('notes.historyModeAutoDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Lightbulb, Minimize2, AlignLeft, Wand2,
|
Lightbulb, Minimize2, AlignLeft, Wand2,
|
||||||
Globe, BookOpen, FileText, RotateCcw, Check,
|
Globe, BookOpen, FileText, RotateCcw, Check,
|
||||||
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
||||||
GitMerge, PlusCircle, Eye, Code,
|
GitMerge, PlusCircle, Eye, Code, Languages,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
@@ -64,6 +64,7 @@ const ACTION_IDS = [
|
|||||||
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'clarify' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
|
{ id: 'clarify', icon: Lightbulb, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'clarify' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.clarify' },
|
||||||
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
|
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
|
||||||
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
|
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
|
||||||
|
{ id: 'translate', icon: Languages, apiPath: '/api/ai/reformulate', body: (content: string, _images?: string[], lang?: string) => ({ text: content, option: 'translate', language: lang || 'fr' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.translate' },
|
||||||
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
|
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
|
||||||
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
|
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
|
||||||
]
|
]
|
||||||
@@ -113,6 +114,9 @@ export function ContextualAIChat({
|
|||||||
// Action state
|
// Action state
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||||
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
const [actionPreview, setActionPreview] = useState<{ label: string; text: string } | null>(null)
|
||||||
|
const [showLangPicker, setShowLangPicker] = useState(false)
|
||||||
|
const [translateTarget, setTranslateTarget] = useState('')
|
||||||
|
const [customLangInput, setCustomLangInput] = useState('')
|
||||||
|
|
||||||
// Resource tab state
|
// Resource tab state
|
||||||
const [resourceUrl, setResourceUrl] = useState('')
|
const [resourceUrl, setResourceUrl] = useState('')
|
||||||
@@ -147,11 +151,13 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
const { messages, sendMessage, status, stop } = useChat({ transport })
|
const { messages, sendMessage, status, stop } = useChat({ transport })
|
||||||
|
|
||||||
const isLoading = status === 'submitted' || status === 'streaming'
|
const lastMsg = messages[messages.length - 1]
|
||||||
|
const lastMsgHasContent = lastMsg?.role === 'assistant' && !!getMessageContent(lastMsg)
|
||||||
|
const isLoading = (status === 'submitted' || status === 'streaming') && !lastMsgHasContent
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages])
|
}, [messages, resourcePreview])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
|
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
|
||||||
@@ -169,7 +175,7 @@ export function ContextualAIChat({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Action execution ────────────────────────────────────────────────────────
|
// ── Action execution ────────────────────────────────────────────────────────
|
||||||
const handleAction = async (action: ActionDef) => {
|
const handleAction = async (action: ActionDef, targetLang?: string) => {
|
||||||
// Image-specific action
|
// Image-specific action
|
||||||
if (action.isImageAction) {
|
if (action.isImageAction) {
|
||||||
if (!noteImages || noteImages.length === 0) {
|
if (!noteImages || noteImages.length === 0) {
|
||||||
@@ -215,7 +221,7 @@ export function ContextualAIChat({
|
|||||||
const res = await fetch(action.apiPath, {
|
const res = await fetch(action.apiPath, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(action.body(noteContent, undefined, language)),
|
body: JSON.stringify(action.body(noteContent, undefined, targetLang || language)),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -250,18 +256,18 @@ export function ContextualAIChat({
|
|||||||
setResourceScraping(true)
|
setResourceScraping(true)
|
||||||
try {
|
try {
|
||||||
const result = await scrapePageText(resourceUrl.trim())
|
const result = await scrapePageText(resourceUrl.trim())
|
||||||
if (!result) { toast.error('Impossible de charger cette URL'); return }
|
if (!result) { toast.error(t('ai.resource.failedToLoadUrl')); return }
|
||||||
setResourceText(result.text)
|
setResourceText(result.text)
|
||||||
toast.success(`Page chargée : ${result.title.slice(0, 40)}`)
|
toast.success(t('ai.resource.pageLoaded', { title: result.title.slice(0, 40) }))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Erreur lors du chargement de la page')
|
toast.error(t('ai.resource.pageLoadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setResourceScraping(false)
|
setResourceScraping(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResourcePreview = async () => {
|
const handleResourcePreview = async () => {
|
||||||
if (!resourceText.trim()) { toast.error('Collez du texte ou chargez une URL d\'abord'); return }
|
if (!resourceText.trim()) { toast.error(t('ai.resource.pasteOrUrlFirst')); return }
|
||||||
if (resourceMode === 'replace') {
|
if (resourceMode === 'replace') {
|
||||||
setResourcePreview({ text: resourceText, source: 'paste' })
|
setResourcePreview({ text: resourceText, source: 'paste' })
|
||||||
return
|
return
|
||||||
@@ -279,10 +285,10 @@ export function ContextualAIChat({
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'Erreur IA')
|
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
|
||||||
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || 'Erreur lors de l\'enrichissement')
|
toast.error(e.message || t('ai.resource.enrichError'))
|
||||||
} finally {
|
} finally {
|
||||||
setResourceEnriching(false)
|
setResourceEnriching(false)
|
||||||
}
|
}
|
||||||
@@ -294,22 +300,21 @@ export function ContextualAIChat({
|
|||||||
setResourcePreview(null)
|
setResourcePreview(null)
|
||||||
setResourceText('')
|
setResourceText('')
|
||||||
setResourceUrl('')
|
setResourceUrl('')
|
||||||
toast.success('Contenu appliqué à la note ✓')
|
toast.success(t('ai.resource.contentApplied'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from chat hover-actions: inject a chat message into the note */
|
/** Called from chat hover-actions: inject a chat message into the note */
|
||||||
const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => {
|
const handleInjectFromChat = async (msgText: string, mode: 'replace' | 'complete' | 'merge') => {
|
||||||
if (mode === 'replace') {
|
if (mode === 'replace') {
|
||||||
|
// Stay on chat tab — show preview inline via resourcePreview without switching tabs
|
||||||
setResourceText(msgText)
|
setResourceText(msgText)
|
||||||
setResourceMode('replace')
|
setResourceMode('replace')
|
||||||
setResourcePreview({ text: msgText, source: 'chat' })
|
setResourcePreview({ text: msgText, source: 'chat' })
|
||||||
setActiveTab('resource')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setResourceText(msgText)
|
setResourceText(msgText)
|
||||||
setResourceMode(mode)
|
setResourceMode(mode)
|
||||||
setActiveTab('resource')
|
// Do NOT switch tabs — enrich and show preview in current tab
|
||||||
// Auto-launch enrichment
|
|
||||||
setResourceEnriching(true)
|
setResourceEnriching(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/ai/enrich-from-resource', {
|
const res = await fetch('/api/ai/enrich-from-resource', {
|
||||||
@@ -326,7 +331,7 @@ export function ContextualAIChat({
|
|||||||
if (!res.ok) throw new Error(data.error || 'Erreur IA')
|
if (!res.ok) throw new Error(data.error || 'Erreur IA')
|
||||||
setResourcePreview({ text: data.enrichedContent, source: mode })
|
setResourcePreview({ text: data.enrichedContent, source: mode })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || 'Erreur enrichissement')
|
toast.error(e.message || t('ai.resource.enrichErrorShort'))
|
||||||
} finally {
|
} finally {
|
||||||
setResourceEnriching(false)
|
setResourceEnriching(false)
|
||||||
}
|
}
|
||||||
@@ -351,7 +356,7 @@ export function ContextualAIChat({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
|
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
||||||
IA Note
|
{t('ai.aiNoteTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
|
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
|
||||||
@@ -390,7 +395,7 @@ export function ContextualAIChat({
|
|||||||
{([
|
{([
|
||||||
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
|
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
|
||||||
{ id: 'actions', icon: Wand2, label: t('ai.noteActions') },
|
{ id: 'actions', icon: Wand2, label: t('ai.noteActions') },
|
||||||
{ id: 'resource', icon: ArrowDownToLine, label: 'Ressource' },
|
{ id: 'resource', icon: ArrowDownToLine, label: t('ai.resourceTab') },
|
||||||
] as const).map(tab => (
|
] as const).map(tab => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -457,35 +462,39 @@ export function ContextualAIChat({
|
|||||||
? <MarkdownContent content={content} />
|
? <MarkdownContent content={content} />
|
||||||
: <p>{content}</p>}
|
: <p>{content}</p>}
|
||||||
</div>
|
</div>
|
||||||
{/* Hover-actions — visible only on assistant messages */}
|
{/* Inject buttons — always visible on last assistant msg */}
|
||||||
{isAssistant && onApplyToNote && (
|
{isAssistant && onApplyToNote && (() => {
|
||||||
|
const lastAssistantId = messages.filter(m => m.role === 'assistant').at(-1)?.id
|
||||||
|
const alwaysShow = msg.id === lastAssistantId
|
||||||
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex gap-1 transition-all duration-150',
|
'flex gap-1 transition-all duration-150',
|
||||||
isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 pointer-events-none',
|
(alwaysShow || isHovered) ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||||
)}>
|
)}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleInjectFromChat(content, 'replace')}
|
onClick={() => handleInjectFromChat(content, 'replace')}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-primary/10 hover:text-primary border border-border/40 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-primary/10 hover:text-primary border border-border/40 transition-colors"
|
||||||
title="Remplacer le contenu de la note par ce message"
|
title={t('ai.injectReplaceTitle')}
|
||||||
>
|
>
|
||||||
<Download className="h-2.5 w-2.5" /> Remplacer
|
<Download className="h-2.5 w-2.5" /> {t('ai.injectReplace')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleInjectFromChat(content, 'complete')}
|
onClick={() => handleInjectFromChat(content, 'complete')}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-950/30 border border-border/40 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-950/30 border border-border/40 transition-colors"
|
||||||
title="Compléter la note avec ce message (IA)"
|
title={t('ai.injectCompleteTitle')}
|
||||||
>
|
>
|
||||||
<PlusCircle className="h-2.5 w-2.5" /> Compléter
|
<PlusCircle className="h-2.5 w-2.5" /> {t('ai.injectComplete')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleInjectFromChat(content, 'merge')}
|
onClick={() => handleInjectFromChat(content, 'merge')}
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-violet-50 hover:text-violet-600 dark:hover:bg-violet-950/30 border border-border/40 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] font-medium bg-muted hover:bg-violet-50 hover:text-violet-600 dark:hover:bg-violet-950/30 border border-border/40 transition-colors"
|
||||||
title="Fusionner avec la note (IA)"
|
title={t('ai.injectMergeTitle')}
|
||||||
>
|
>
|
||||||
<GitMerge className="h-2.5 w-2.5" /> Fusionner
|
<GitMerge className="h-2.5 w-2.5" /> {t('ai.injectMerge')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -502,9 +511,107 @@ export function ContextualAIChat({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Enrich-in-progress indicator */}
|
||||||
|
{resourceEnriching && !isLoading && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-emerald-500/10 text-emerald-600 flex items-center justify-center flex-shrink-0 border border-emerald-500/20">
|
||||||
|
<Wand2 className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-muted/40 border border-border/40 p-3 rounded-2xl rounded-tl-sm flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-600" />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('ai.resource.enriching') || 'Traitement IA...'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline preview from inject buttons */}
|
||||||
|
{resourcePreview && !resourceEnriching && (
|
||||||
|
<div className="rounded-xl border border-primary/30 bg-primary/5 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-primary/20">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
|
||||||
|
{resourcePreview.source === 'chat' ? (t('ai.resource.fromChat') || 'Remplacement')
|
||||||
|
: resourcePreview.source === 'replace' ? (t('ai.resource.replacement') || 'Remplacement')
|
||||||
|
: resourcePreview.source === 'complete' ? (t('ai.resource.completedByAI') || 'Complété par IA')
|
||||||
|
: (t('ai.resource.mergedByAI') || 'Fusionné par IA')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 max-h-48 overflow-y-auto">
|
||||||
|
<MarkdownContent content={resourcePreview.text} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 px-3 py-2 border-t border-primary/20">
|
||||||
|
<button
|
||||||
|
onClick={() => setResourcePreview(null)}
|
||||||
|
className="flex-1 text-[11px] py-1.5 rounded-lg border border-border/60 text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.cancel') || 'Annuler'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (onApplyToNote && resourcePreview) { onApplyToNote(resourcePreview.text); setResourcePreview(null); setResourceText(''); toast.success(t('ai.appliedToNote')) } }}
|
||||||
|
disabled={!onApplyToNote}
|
||||||
|
className="flex-1 text-[11px] py-1.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
{t('ai.resource.applyToNote') || 'Appliquer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ══ Inject Preview Panel — fixed, always visible, NO scroll needed ══ */}
|
||||||
|
{resourceEnriching && (
|
||||||
|
<div className="shrink-0 mx-3 mb-2 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-50/50 dark:bg-emerald-950/20 p-3">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-600 shrink-0" />
|
||||||
|
<span className="text-xs text-emerald-700 dark:text-emerald-400">Traitement IA en cours...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resourcePreview && !resourceEnriching && (
|
||||||
|
<div className="shrink-0 mx-3 mb-2 rounded-xl border border-primary/30 bg-primary/5 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-primary/20">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-primary">
|
||||||
|
{resourcePreview.source === 'complete' ? 'Complété par IA'
|
||||||
|
: resourcePreview.source === 'merge' ? 'Fusionné par IA'
|
||||||
|
: 'Aperçu'}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 max-h-36 overflow-y-auto text-sm">
|
||||||
|
<MarkdownContent content={resourcePreview.text} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 px-3 py-2 border-t border-primary/20">
|
||||||
|
<button
|
||||||
|
onClick={() => setResourcePreview(null)}
|
||||||
|
className="flex-1 text-[11px] py-1.5 rounded-lg border border-border/60 text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (onApplyToNote && resourcePreview) {
|
||||||
|
onApplyToNote(resourcePreview.text)
|
||||||
|
setResourcePreview(null)
|
||||||
|
setResourceText('')
|
||||||
|
toast.success('Appliqué à la note')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!onApplyToNote}
|
||||||
|
className="flex-1 text-[11px] py-1.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
Appliquer à la note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scope & Tone Control Area */}
|
{/* Scope & Tone Control Area */}
|
||||||
<div className="border-t border-border/20 shrink-0 bg-muted/10">
|
<div className="border-t border-border/20 shrink-0 bg-muted/10">
|
||||||
{/* Scope bar */}
|
{/* Scope bar */}
|
||||||
@@ -695,7 +802,7 @@ export function ContextualAIChat({
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span>{t(action.i18nKey)}</span>
|
<span>{t(action.i18nKey)}</span>
|
||||||
{noteImages.length > 1 && (
|
{noteImages.length > 1 && (
|
||||||
<span className="text-[10px] text-muted-foreground">{noteImages.length} images</span>
|
<span className="text-[10px] text-muted-foreground">{t('ai.imagesCount', { count: noteImages.length })}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
|
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
|
||||||
@@ -715,6 +822,49 @@ export function ContextualAIChat({
|
|||||||
ACTION_IDS.filter(a => !a.isImageAction).map(action => {
|
ACTION_IDS.filter(a => !a.isImageAction).map(action => {
|
||||||
const Icon = action.icon
|
const Icon = action.icon
|
||||||
const loading = actionLoading === action.id
|
const loading = actionLoading === action.id
|
||||||
|
if (action.id === 'translate') {
|
||||||
|
return (
|
||||||
|
<div key={action.id} className="flex flex-col gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLangPicker(v => !v)}
|
||||||
|
disabled={!!actionLoading}
|
||||||
|
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" /> : <Icon className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
<span>{t(action.i18nKey)}</span>
|
||||||
|
{translateTarget && <span className="ml-auto text-xs text-primary/70 font-normal">{translateTarget}</span>}
|
||||||
|
</button>
|
||||||
|
{showLangPicker && (
|
||||||
|
<div className="flex flex-col gap-2 px-3 py-3 rounded-xl border border-border/40 bg-muted/30">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{['Francais','English','Espanol','Deutsch','Persan','Portugais','Italiano','Chinois','Japonais'].map(l => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-lg border transition-colors ${translateTarget === l ? 'bg-primary text-primary-foreground border-primary' : 'bg-card border-border hover:bg-accent'}`}
|
||||||
|
onClick={() => setTranslateTarget(l)}
|
||||||
|
>{l}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="text-xs px-3 py-1.5 rounded-lg border border-border bg-card outline-none focus:border-primary w-full"
|
||||||
|
placeholder={t('ai.action.customLang') || 'Autre langue...'}
|
||||||
|
value={customLangInput}
|
||||||
|
onChange={e => setCustomLangInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
disabled={!!actionLoading || !translateTarget}
|
||||||
|
onClick={() => handleAction(action, translateTarget)}
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50 hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <Languages className="h-3 w-3" />}
|
||||||
|
{t('ai.action.translate')} {translateTarget}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={action.id}
|
key={action.id}
|
||||||
@@ -760,10 +910,10 @@ export function ContextualAIChat({
|
|||||||
<div className="px-4 py-2.5 border-b border-border/40 flex items-center justify-between shrink-0">
|
<div className="px-4 py-2.5 border-b border-border/40 flex items-center justify-between shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs font-semibold text-foreground">
|
<p className="text-xs font-semibold text-foreground">
|
||||||
{resourcePreview.source === 'chat' ? '💬 Depuis le chat'
|
{resourcePreview.source === 'chat' ? t('ai.resource.fromChat')
|
||||||
: resourcePreview.source === 'replace' ? '↓ Remplacement'
|
: resourcePreview.source === 'replace' ? t('ai.resource.replacement')
|
||||||
: resourcePreview.source === 'complete' ? '✦ Complété par IA'
|
: resourcePreview.source === 'complete' ? t('ai.resource.completedByAI')
|
||||||
: '⟳ Fusionné par IA'}
|
: t('ai.resource.mergedByAI')}
|
||||||
</p>
|
</p>
|
||||||
{/* Format toggle */}
|
{/* Format toggle */}
|
||||||
<div className="flex rounded-md border border-border/40 overflow-hidden">
|
<div className="flex rounded-md border border-border/40 overflow-hidden">
|
||||||
@@ -776,7 +926,7 @@ export function ContextualAIChat({
|
|||||||
: 'bg-card text-muted-foreground hover:bg-muted',
|
: 'bg-card text-muted-foreground hover:bg-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Eye className="h-2.5 w-2.5" /> Rendu
|
<Eye className="h-2.5 w-2.5" /> {t('ai.resource.rendered')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setResourcePreviewFormat('markdown')}
|
onClick={() => setResourcePreviewFormat('markdown')}
|
||||||
@@ -814,7 +964,7 @@ export function ContextualAIChat({
|
|||||||
className="flex-1 text-xs gap-1.5"
|
className="flex-1 text-xs gap-1.5"
|
||||||
onClick={() => setResourcePreview(null)}
|
onClick={() => setResourcePreview(null)}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" /> Annuler
|
<X className="h-3.5 w-3.5" /> {t('ai.resource.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -822,7 +972,7 @@ export function ContextualAIChat({
|
|||||||
onClick={handleApplyResourcePreview}
|
onClick={handleApplyResourcePreview}
|
||||||
disabled={!onApplyToNote}
|
disabled={!onApplyToNote}
|
||||||
>
|
>
|
||||||
<Check className="h-3.5 w-3.5" /> Appliquer à la note
|
<Check className="h-3.5 w-3.5" /> {t('ai.resource.applyToNote')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,7 +982,7 @@ export function ContextualAIChat({
|
|||||||
{/* URL loader */}
|
{/* URL loader */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
|
||||||
<Link2 className="h-3 w-3 inline mr-1" />URL (optionnel)
|
<Link2 className="h-3 w-3 inline mr-1" />{t('ai.resource.urlLabel')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<input
|
<input
|
||||||
@@ -860,18 +1010,18 @@ export function ContextualAIChat({
|
|||||||
{/* Text area */}
|
{/* Text area */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
|
||||||
<FileText className="h-3 w-3 inline mr-1" />Texte de la ressource
|
<FileText className="h-3 w-3 inline mr-1" />{t('ai.resource.resourceText')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={resourceText}
|
value={resourceText}
|
||||||
onChange={e => setResourceText(e.target.value)}
|
onChange={e => setResourceText(e.target.value)}
|
||||||
placeholder="Collez votre texte ici (markdown, HTML, texte brut…)"
|
placeholder={t('ai.resource.resourcePlaceholder')}
|
||||||
rows={8}
|
rows={8}
|
||||||
className="w-full px-2.5 py-2 text-xs bg-card border border-border/60 rounded-lg focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 resize-none transition-all font-mono leading-relaxed"
|
className="w-full px-2.5 py-2 text-xs bg-card border border-border/60 rounded-lg focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 resize-none transition-all font-mono leading-relaxed"
|
||||||
/>
|
/>
|
||||||
{resourceText && (
|
{resourceText && (
|
||||||
<p className="text-[9px] text-muted-foreground mt-0.5 text-right">
|
<p className="text-[9px] text-muted-foreground mt-0.5 text-right">
|
||||||
{resourceText.split(/\s+/).filter(Boolean).length} mots
|
{resourceText.split(/\s+/).filter(Boolean).length} {t('ai.resource.words')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -879,13 +1029,13 @@ export function ContextualAIChat({
|
|||||||
{/* Mode selector */}
|
{/* Mode selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground block mb-1.5">
|
||||||
Mode d'intégration
|
{t('ai.resource.integrationMode')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{([
|
{([
|
||||||
{ id: 'replace', label: 'Remplacer', desc: 'Direct, sans IA', icon: Download, color: 'primary' },
|
{ id: 'replace', label: t('ai.resource.modeReplace'), desc: t('ai.resource.modeReplaceDesc'), icon: Download, color: 'primary' },
|
||||||
{ id: 'complete', label: 'Compléter', desc: 'Ajoute sans réécrire', icon: PlusCircle, color: 'emerald' },
|
{ id: 'complete', label: t('ai.resource.modeComplete'), desc: t('ai.resource.modeCompleteDesc'), icon: PlusCircle, color: 'emerald' },
|
||||||
{ id: 'merge', label: 'Fusionner', desc: 'Réécrit et intègre', icon: GitMerge, color: 'violet' },
|
{ id: 'merge', label: t('ai.resource.modeMerge'), desc: t('ai.resource.modeMergeDesc'), icon: GitMerge, color: 'violet' },
|
||||||
] as const).map(m => {
|
] as const).map(m => {
|
||||||
const Icon = m.icon
|
const Icon = m.icon
|
||||||
const sel = resourceMode === m.id
|
const sel = resourceMode === m.id
|
||||||
@@ -920,17 +1070,17 @@ export function ContextualAIChat({
|
|||||||
disabled={!resourceText.trim() || resourceEnriching}
|
disabled={!resourceText.trim() || resourceEnriching}
|
||||||
>
|
>
|
||||||
{resourceEnriching
|
{resourceEnriching
|
||||||
? <><Loader2 className="h-4 w-4 animate-spin" /> IA en cours…</>
|
? <><Loader2 className="h-4 w-4 animate-spin" /> {t('ai.resource.aiProcessing')}</>
|
||||||
: resourceMode === 'replace'
|
: resourceMode === 'replace'
|
||||||
? <><Eye className="h-4 w-4" /> Aperçu</>
|
? <><Eye className="h-4 w-4" /> {t('ai.resource.preview')}</>
|
||||||
: <><Sparkles className="h-4 w-4" /> Générer l'aperçu</>
|
: <><Sparkles className="h-4 w-4" /> {t('ai.resource.generatePreview')}</>
|
||||||
}
|
}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Hint */}
|
{/* Hint */}
|
||||||
{!noteContent && resourceMode !== 'replace' && (
|
{!noteContent && resourceMode !== 'replace' && (
|
||||||
<p className="text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
|
<p className="text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
|
||||||
💡 La note est vide — le contenu de la ressource sera intégré directement.
|
{t('ai.resource.emptyNoteHint')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -941,3 +1091,6 @@ export function ContextualAIChat({
|
|||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
// Paste handler: upload clipboard images
|
// Paste handler: upload clipboard images
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -237,8 +238,8 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('paste', handlePaste)
|
document.addEventListener('paste', handlePaste, { capture: true })
|
||||||
return () => document.removeEventListener('paste', handlePaste)
|
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
const handleRemoveImage = (index: number) => {
|
const handleRemoveImage = (index: number) => {
|
||||||
@@ -276,6 +277,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
setLinks(links.filter((_, i) => i !== index))
|
setLinks(links.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allImages = useMemo(() => {
|
||||||
|
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||||
|
return Array.from(new Set([...images, ...extracted]));
|
||||||
|
}, [images, content, noteType]);
|
||||||
|
|
||||||
const handleGenerateTitles = async () => {
|
const handleGenerateTitles = async () => {
|
||||||
// Combine content and link metadata for AI
|
// Combine content and link metadata for AI
|
||||||
const fullContentForAI = [
|
const fullContentForAI = [
|
||||||
@@ -304,7 +310,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
const response = await fetch('/api/ai/title-suggestions', {
|
const response = await fetch('/api/ai/title-suggestions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: fullContent }),
|
body: JSON.stringify({ content: fullContentForAI }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -733,6 +739,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
content={content}
|
content={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
className="min-h-[200px]"
|
className="min-h-[200px]"
|
||||||
|
onImageUpload={uploadImageFile}
|
||||||
/>
|
/>
|
||||||
) : noteType === 'text' || noteType === 'markdown' ? (
|
) : noteType === 'text' || noteType === 'markdown' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1158,3 +1165,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ export function NoteInlineEditor({
|
|||||||
// Paste handler: upload clipboard images
|
// Paste handler: upload clipboard images
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -412,8 +413,8 @@ export function NoteInlineEditor({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('paste', handlePaste)
|
document.addEventListener('paste', handlePaste, { capture: true })
|
||||||
return () => document.removeEventListener('paste', handlePaste)
|
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||||
}, [note.id, note.images, onChange, t])
|
}, [note.id, note.images, onChange, t])
|
||||||
|
|
||||||
const handleRemoveImage = async (index: number) => {
|
const handleRemoveImage = async (index: number) => {
|
||||||
@@ -938,3 +939,5 @@ export function NoteInlineEditor({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,30 @@ import { useSession } from 'next-auth/react'
|
|||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
|
||||||
|
/** Convert basic markdown to HTML for richtext injection from AI chat */
|
||||||
|
function markdownToBasicHtml(md: string): string {
|
||||||
|
return md
|
||||||
|
.replace(/^#{6}\s(.+)$/gm, '<h6>$1</h6>')
|
||||||
|
.replace(/^#{5}\s(.+)$/gm, '<h5>$1</h5>')
|
||||||
|
.replace(/^#{4}\s(.+)$/gm, '<h4>$1</h4>')
|
||||||
|
.replace(/^#{3}\s(.+)$/gm, '<h3>$1</h3>')
|
||||||
|
.replace(/^#{2}\s(.+)$/gm, '<h2>$1</h2>')
|
||||||
|
.replace(/^#\s(.+)$/gm, '<h1>$1</h1>')
|
||||||
|
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||||
|
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||||
|
.replace(/^[-*]\s(.+)$/gm, '<li>$1</li>')
|
||||||
|
.replace(/^\d+\.\s(.+)$/gm, '<li>$1</li>')
|
||||||
|
.replace(/^>\s(.+)$/gm, '<blockquote>$1</blockquote>')
|
||||||
|
.replace(/\n{2,}/g, '</p><p>')
|
||||||
|
.replace(/^(?!<[hH1-6]|<blockquote|<li|<pre|<p)(.+)$/gm, '<p>$1</p>')
|
||||||
|
.replace(/<p><\/p>/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
interface HistoryState {
|
interface HistoryState {
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
@@ -452,6 +476,7 @@ export function NoteInput({
|
|||||||
// Paste handler: upload clipboard images
|
// Paste handler: upload clipboard images
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
if (type === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -468,8 +493,8 @@ export function NoteInput({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('paste', handlePaste)
|
document.addEventListener('paste', handlePaste, { capture: true })
|
||||||
return () => document.removeEventListener('paste', handlePaste)
|
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
// AI title from images
|
// AI title from images
|
||||||
@@ -774,6 +799,7 @@ export function NoteInput({
|
|||||||
content={content}
|
content={content}
|
||||||
onChange={setContent}
|
onChange={setContent}
|
||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
|
onImageUpload={uploadImageFile}
|
||||||
/>
|
/>
|
||||||
) : type === 'text' || type === 'markdown' ? (
|
) : type === 'text' || type === 'markdown' ? (
|
||||||
<>
|
<>
|
||||||
@@ -1042,7 +1068,15 @@ export function NoteInput({
|
|||||||
noteTitle={title}
|
noteTitle={title}
|
||||||
noteContent={content}
|
noteContent={content}
|
||||||
noteImages={allImages}
|
noteImages={allImages}
|
||||||
onApplyToNote={(newContent) => setContent(newContent)}
|
onApplyToNote={(newContent) => {
|
||||||
|
if (type === 'richtext') {
|
||||||
|
// If content looks like markdown, convert to HTML before injecting into richtext
|
||||||
|
const looksLikeMarkdown = /^#{1,6}\s|^[-*]\s|\*\*[^*]+\*\*|^>\s/.test(newContent)
|
||||||
|
setContent(looksLikeMarkdown ? markdownToBasicHtml(newContent) : newContent)
|
||||||
|
} else {
|
||||||
|
setContent(newContent)
|
||||||
|
}
|
||||||
|
}}
|
||||||
lastActionApplied={false}
|
lastActionApplied={false}
|
||||||
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
notebooks={notebooks.map(nb => ({ id: nb.id, name: nb.name }))}
|
||||||
className="border border-border border-l-0 rounded-r-xl overflow-hidden shadow-sm"
|
className="border border-border border-l-0 rounded-r-xl overflow-hidden shadow-sm"
|
||||||
@@ -1101,3 +1135,4 @@ export function NoteInput({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import {
|
|||||||
History,
|
History,
|
||||||
PanelRightClose,
|
PanelRightClose,
|
||||||
PanelRightOpen,
|
PanelRightOpen,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
Bell,
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -640,6 +642,24 @@ export function NotesTabsView({
|
|||||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
|
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('date-desc')
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
const [listOpen, setListOpen] = useState(true)
|
||||||
|
|
||||||
|
// Auto-hide meta sidebar when AI opens; restore previous state when AI closes
|
||||||
|
const prevSidebarOpen = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const visible = (e as CustomEvent<boolean>).detail
|
||||||
|
if (visible) {
|
||||||
|
prevSidebarOpen.current = sidebarOpen
|
||||||
|
setSidebarOpen(false)
|
||||||
|
} else {
|
||||||
|
setSidebarOpen(prevSidebarOpen.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('contextual-ai-visibility', handler)
|
||||||
|
return () => window.removeEventListener('contextual-ai-visibility', handler)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [sidebarOpen])
|
||||||
|
|
||||||
const prevNotesRef = useRef<Note[]>(notes)
|
const prevNotesRef = useRef<Note[]>(notes)
|
||||||
|
|
||||||
@@ -756,6 +776,7 @@ export function NotesTabsView({
|
|||||||
const selected = items.find((n) => n.id === selectedId) ?? null
|
const selected = items.find((n) => n.id === selectedId) ?? null
|
||||||
const colorKey = selected ? getColorKey(selected) : 'default'
|
const colorKey = selected ? getColorKey(selected) : 'default'
|
||||||
|
|
||||||
|
|
||||||
const handleCreateNote = (noteType: NoteType = 'richtext') => {
|
const handleCreateNote = (noteType: NoteType = 'richtext') => {
|
||||||
startCreating(async () => {
|
startCreating(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -816,19 +837,21 @@ export function NotesTabsView({
|
|||||||
data-testid="notes-grid-tabs"
|
data-testid="notes-grid-tabs"
|
||||||
>
|
>
|
||||||
{/* ── Left panel: note list ── */}
|
{/* ── Left panel: note list ── */}
|
||||||
<div className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">
|
<div className={cn('flex shrink-0 flex-col border-r border-border/60 bg-background transition-all duration-200', listOpen ? 'w-64' : 'w-9')}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header — always visible */}
|
||||||
<div className="flex items-center justify-between border-b border-border/60 bg-background/95 px-4 py-3.5">
|
<div className={cn('flex items-center border-b border-border/60 bg-background/95 py-3.5 transition-all', listOpen ? 'justify-between px-4' : 'justify-center px-1')}>
|
||||||
<div className="flex items-center gap-2">
|
{listOpen && (
|
||||||
<span className="text-sm font-semibold tracking-tight text-foreground">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{t('notes.title')}
|
<span className="text-sm font-semibold tracking-tight text-foreground">
|
||||||
</span>
|
{t('notes.title')}
|
||||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
</span>
|
||||||
{items.length}
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||||
</span>
|
{items.length}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn('flex items-center', listOpen ? 'gap-1' : 'flex-col gap-1')}>
|
||||||
{/* Sort / filter button */}
|
{/* Sort / filter button */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -892,10 +915,23 @@ export function NotesTabsView({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{/* Collapse list panel */}
|
||||||
|
{listOpen && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
|
||||||
|
onClick={() => setListOpen(false)}
|
||||||
|
title="R\u00e9duire la liste"
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable note list */}
|
{/* Scrollable note list */}
|
||||||
|
{listOpen && (
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto overscroll-contain bg-background"
|
className="flex-1 overflow-y-auto overscroll-contain bg-background"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
@@ -939,6 +975,21 @@ export function NotesTabsView({
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Expand button shown in collapsed state */}
|
||||||
|
{!listOpen && (
|
||||||
|
<div className="flex flex-col items-center pt-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"
|
||||||
|
onClick={() => setListOpen(true)}
|
||||||
|
title="Afficher la liste"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right content panel ── */}
|
{/* ── Right content panel ── */}
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ import {
|
|||||||
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
|
||||||
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
|
||||||
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
|
||||||
} from 'lucide-react'
|
SpellCheck, Languages, BookOpen } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export interface RichTextEditorHandle {
|
export interface RichTextEditorHandle {
|
||||||
getEditor: () => Editor | null
|
getEditor: () => Editor | null
|
||||||
@@ -38,6 +39,7 @@ interface RichTextEditorProps {
|
|||||||
onChange?: (content: string) => void
|
onChange?: (content: string) => void
|
||||||
className?: string
|
className?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
onImageUpload?: (file: File) => Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlashItem = {
|
type SlashItem = {
|
||||||
@@ -58,6 +60,7 @@ const CustomImage = Image.extend({
|
|||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
width: {
|
width: {
|
||||||
default: '100%',
|
default: '100%',
|
||||||
|
parseHTML: element => element.style.width || element.getAttribute('width') || '100%',
|
||||||
renderHTML: attributes => {
|
renderHTML: attributes => {
|
||||||
if (!attributes.width) return {}
|
if (!attributes.width) return {}
|
||||||
return { style: `width: ${attributes.width}; max-width: 100%; height: auto;` }
|
return { style: `width: ${attributes.width}; max-width: 100%; height: auto;` }
|
||||||
@@ -92,11 +95,11 @@ const slashCommands: SlashItem[] = [
|
|||||||
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
|
{ title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => {} },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function aiReformulate(text: string, option: 'clarify' | 'shorten' | 'improve'): Promise<string> {
|
async function aiReformulate(text: string, option: string, language?: string): Promise<string> {
|
||||||
const res = await fetch('/api/ai/reformulate', {
|
const res = await fetch('/api/ai/reformulate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text, option, format: 'html' }),
|
body: JSON.stringify({ text, option, format: 'html', language }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || 'AI failed')
|
if (!res.ok) throw new Error(data.error || 'AI failed')
|
||||||
@@ -129,7 +132,7 @@ function useImageInsert() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
|
||||||
function RichTextEditor({ content, onChange, className, placeholder }, ref) {
|
function RichTextEditor({ content, onChange, className, placeholder, onImageUpload }, ref) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const imageInsert = useImageInsert()
|
const imageInsert = useImageInsert()
|
||||||
|
|
||||||
@@ -152,6 +155,27 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
|
|||||||
immediatelyRender: false,
|
immediatelyRender: false,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: { class: 'notion-editor' },
|
attributes: { class: 'notion-editor' },
|
||||||
|
handlePaste: (view, event, slice) => {
|
||||||
|
if (!onImageUpload) return false;
|
||||||
|
const items = Array.from(event.clipboardData?.items || []);
|
||||||
|
const hasImage = items.some(item => item.type.startsWith('image/'));
|
||||||
|
if (!hasImage) return false;
|
||||||
|
event.preventDefault();
|
||||||
|
const images = items.filter(item => item.type.startsWith('image/')).map(item => item.getAsFile()).filter(f => f !== null) as File[];
|
||||||
|
images.forEach(async (file) => {
|
||||||
|
try {
|
||||||
|
toast.info(t('notes.uploading'));
|
||||||
|
const url = await onImageUpload(file);
|
||||||
|
const { schema } = view.state;
|
||||||
|
const node = schema.nodes.image.create({ src: url });
|
||||||
|
const tr = view.state.tr.replaceSelectionWith(node);
|
||||||
|
view.dispatch(tr);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('notes.uploadFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onUpdate: ({ editor: e }) => {
|
onUpdate: ({ editor: e }) => {
|
||||||
const html = e.getHTML()
|
const html = e.getHTML()
|
||||||
@@ -252,14 +276,19 @@ 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 }) {
|
function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
||||||
const { t } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const [, setTick] = useState(0)
|
const [, setTick] = useState(0)
|
||||||
const [aiOpen, setAiOpen] = useState(false)
|
const [aiOpen, setAiOpen] = useState(false)
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
const [linkOpen, setLinkOpen] = useState(false)
|
const [linkOpen, setLinkOpen] = useState(false)
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
const linkInputRef = useRef<HTMLInputElement>(null)
|
const linkInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [translateOpen, setTranslateOpen] = useState(false)
|
||||||
|
const [customLang, setCustomLang] = useState('')
|
||||||
|
const [aiModal, setAiModal] = useState<{ type: 'explain' | 'preview'; origText: string; html: string; from: number; to: number } | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -286,15 +315,21 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
{ icon: SubscriptIcon, active: editor.isActive('subscript'), action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') },
|
{ icon: SubscriptIcon, active: editor.isActive('subscript'), action: () => editor.chain().focus().toggleSubscript().run(), title: t('richTextEditor.subscript') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleAI = async (option: 'clarify' | 'shorten' | 'improve') => {
|
const handleAI = async (option: 'clarify' | 'shorten' | 'improve' | 'fix_grammar' | 'translate' | 'explain', targetLang?: string) => {
|
||||||
const { from, to } = editor.state.selection
|
const { from, to } = editor.state.selection
|
||||||
const text = editor.state.doc.textBetween(from, to, ' ')
|
const text = editor.state.doc.textBetween(from, to, ' ')
|
||||||
if (!text || text.split(/\s+/).length < 5) return
|
if (!text || text.split(/\s+/).length < 2) return
|
||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
setAiOpen(false)
|
setAiOpen(false)
|
||||||
|
setTranslateOpen(false)
|
||||||
try {
|
try {
|
||||||
const result = await aiReformulate(text, option)
|
const lang = option === 'translate' ? (targetLang || language) : language
|
||||||
editor.chain().focus().insertContentAt({ from, to }, result).run()
|
const result = await aiReformulate(text, option, lang)
|
||||||
|
if (option === 'explain') {
|
||||||
|
setAiModal({ type: 'explain', origText: text, html: result, from, to })
|
||||||
|
} else {
|
||||||
|
setAiModal({ type: 'preview', origText: text, html: result, from, to })
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('AI error:', err)
|
console.error('AI error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -302,6 +337,13 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyAiResult = () => {
|
||||||
|
if (!aiModal || !editor) { setAiModal(null); return }
|
||||||
|
const clean = aiModal.html.replace(/^<p>([\s\S]*)<\/p>$/, '$1').trim()
|
||||||
|
editor.chain().focus().insertContentAt({ from: aiModal.from, to: aiModal.to }, clean).run()
|
||||||
|
setAiModal(null)
|
||||||
|
}
|
||||||
|
|
||||||
const openLinkEditor = () => {
|
const openLinkEditor = () => {
|
||||||
const existing = editor.getAttributes('link').href || ''
|
const existing = editor.getAttributes('link').href || ''
|
||||||
setLinkUrl(existing)
|
setLinkUrl(existing)
|
||||||
@@ -365,6 +407,53 @@ function BubbleToolbar({ editor }: { editor: Editor | null }) {
|
|||||||
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>{t('richTextEditor.slashClarify')}</span></button>
|
||||||
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>{t('richTextEditor.slashShorten')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>{t('richTextEditor.slashShorten')}</span></button>
|
||||||
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
|
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>{t('richTextEditor.slashImprove')}</span></button>
|
||||||
|
<button className="notion-ai-subitem" onClick={() => handleAI('fix_grammar')}><SpellCheck className="w-3.5 h-3.5 text-green-500" /><span>{t('ai.action.fixGrammar')}</span></button>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-1 px-1 pt-1 w-full border-t border-border/40 mt-1">
|
||||||
|
<input
|
||||||
|
className="notion-inline-input text-xs flex-1 min-w-0"
|
||||||
|
placeholder={t('ai.action.customLang') || 'Autre...'}
|
||||||
|
value={customLang}
|
||||||
|
onChange={e => setCustomLang(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && customLang.trim()) handleAI('translate', customLang.trim()) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="notion-ai-subitem" onClick={() => handleAI('explain')}><BookOpen className="w-3.5 h-3.5 text-orange-500" /><span>{t('ai.action.explain')}</span></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{aiModal && (
|
||||||
|
<div className="notion-ai-result-overlay" onClick={() => setAiModal(null)}>
|
||||||
|
<div className="notion-ai-result-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="notion-ai-result-header">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{aiModal.type === 'explain' ? t('ai.action.explain') : (t('ai.result.preview') || 'Apercu IA')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setAiModal(null)} className="notion-bubble-btn ml-auto"><X className="w-3.5 h-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
{aiModal.type === 'preview' && (
|
||||||
|
<div className="notion-ai-result-section">
|
||||||
|
<span className="notion-ai-result-label">{t('ai.result.original') || 'Original'}</span>
|
||||||
|
<p className="text-sm text-muted-foreground line-through opacity-60 mt-1">{aiModal.origText}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="notion-ai-result-section">
|
||||||
|
{aiModal.type === 'preview' && <span className="notion-ai-result-label">{t('ai.result.suggestion') || 'Suggestion'}</span>}
|
||||||
|
<div className="prose prose-sm max-w-none text-sm mt-1" dangerouslySetInnerHTML={{ __html: aiModal.html }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-border/40">
|
||||||
|
<button onClick={() => setAiModal(null)} className="notion-modal-btn">{t('common.cancel') || 'Annuler'}</button>
|
||||||
|
{aiModal.type === 'preview' && (
|
||||||
|
<button onClick={applyAiResult} className="notion-modal-btn notion-modal-btn-primary">{t('ai.result.apply') || 'Appliquer'}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -631,3 +720,7 @@ function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertI
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -10,7 +10,7 @@ import { LanguageDetectionService } from './language-detection.service'
|
|||||||
import { getTagsProvider } from '../factory'
|
import { getTagsProvider } from '../factory'
|
||||||
import { getSystemConfig } from '@/lib/config'
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle'
|
export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar' | 'translate' | 'explain'
|
||||||
|
|
||||||
export interface RefactorOption {
|
export interface RefactorOption {
|
||||||
mode: RefactorMode
|
mode: RefactorMode
|
||||||
@@ -68,7 +68,8 @@ export class ParagraphRefactorService {
|
|||||||
async refactor(
|
async refactor(
|
||||||
content: string,
|
content: string,
|
||||||
mode: RefactorMode,
|
mode: RefactorMode,
|
||||||
format: 'html' | 'markdown' = 'markdown'
|
format: 'html' | 'markdown' = 'markdown',
|
||||||
|
targetLanguage?: string
|
||||||
): Promise<RefactorResult> {
|
): Promise<RefactorResult> {
|
||||||
// Validate word count
|
// Validate word count
|
||||||
const wordCount = content.split(/\s+/).length
|
const wordCount = content.split(/\s+/).length
|
||||||
@@ -78,8 +79,9 @@ export class ParagraphRefactorService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect language
|
// Detect language or use provided target language
|
||||||
const { language } = await this.languageDetection.detectLanguage(content)
|
const { language: detectedLanguage } = await this.languageDetection.detectLanguage(content)
|
||||||
|
const language = targetLanguage || detectedLanguage
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build prompts
|
// Build prompts
|
||||||
@@ -233,7 +235,24 @@ Remove fluff, repetition, and unnecessary words, but keep the substance.${format
|
|||||||
Your goal: Enhance the text's style, vocabulary, sentence structure, and overall quality.
|
Your goal: Enhance the text's style, vocabulary, sentence structure, and overall quality.
|
||||||
|
|
||||||
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
||||||
Maintain similar length but make it sound more professional and polished.${formatInstruction}`
|
Maintain similar length but make it sound more professional and polished.${formatInstruction}`,
|
||||||
|
|
||||||
|
fix_grammar: `You are an expert proofreader.
|
||||||
|
Your goal: Fix spelling, grammar, and punctuation errors in the text without changing its meaning, tone, or style.
|
||||||
|
|
||||||
|
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. NEVER translate to English.
|
||||||
|
Make minimal changes, only correcting errors.${formatInstruction}`,
|
||||||
|
|
||||||
|
translate: `You are a professional translator.
|
||||||
|
Your goal: Translate the text perfectly into the target language requested by the user.
|
||||||
|
Ensure the translation sounds natural and preserves the original tone and formatting.${formatInstruction}`,
|
||||||
|
|
||||||
|
explain: `You are an expert teacher and encyclopedia.
|
||||||
|
Your goal: Explain the selected text, concept, or word clearly and concisely.
|
||||||
|
Provide context, definitions, or relevant information to help the user understand it.
|
||||||
|
|
||||||
|
CRITICAL LANGUAGE RULE: You MUST explain it in the requested language (which is the user's interface language).
|
||||||
|
Keep it concise but informative.${formatInstruction}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return prompts[mode]
|
return prompts[mode]
|
||||||
@@ -254,7 +273,17 @@ Please shorten this ${language} text while keeping all key information:`,
|
|||||||
|
|
||||||
improveStyle: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
improveStyle: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
||||||
|
|
||||||
Please improve the style and readability of this ${language} text:`
|
Please improve the style and readability of this ${language} text:`,
|
||||||
|
|
||||||
|
fix_grammar: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
||||||
|
|
||||||
|
Please fix any spelling, grammar, or punctuation errors in this ${language} text:`,
|
||||||
|
|
||||||
|
translate: `Please translate the following text into ${language}.
|
||||||
|
Only return the translated text, nothing else.`,
|
||||||
|
|
||||||
|
explain: `Please explain the following text/concept in ${language}.
|
||||||
|
Keep the explanation clear, educational, and concise.`
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${instructions[mode]}
|
return `${instructions[mode]}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"noLabelsInNotebook": "هنوز برچسبی در این دفترچه وجود ندارد",
|
"noLabelsInNotebook": "هنوز برچسبی در این دفترچه وجود ندارد",
|
||||||
"archive": "بایگانی",
|
"archive": "بایگانی",
|
||||||
"trash": "زبالهدان",
|
"trash": "زبالهدان",
|
||||||
"clearFilter": "Remove filter"
|
"clearFilter": "حذف فیلتر"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"title": "یادداشتها",
|
"title": "یادداشتها",
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
"itemOrMediaRequired": "لطفا حداقل یک آیتم یا رسانه اضافه کنید",
|
"itemOrMediaRequired": "لطفا حداقل یک آیتم یا رسانه اضافه کنید",
|
||||||
"noteCreated": "یادداشت با موفقیت ایجاد شد",
|
"noteCreated": "یادداشت با موفقیت ایجاد شد",
|
||||||
"noteCreateFailed": "ایجاد یادداشت شکست خورد",
|
"noteCreateFailed": "ایجاد یادداشت شکست خورد",
|
||||||
"deleted": "Note deleted",
|
"deleted": "یادداشت حذف شد",
|
||||||
"deleteFailed": "Failed to delete note",
|
"deleteFailed": "حذف یادداشت ناموفق بود",
|
||||||
"aiAssistant": "دستیار هوش مصنوعی",
|
"aiAssistant": "دستیار هوش مصنوعی",
|
||||||
"changeSize": "تغییر اندازه",
|
"changeSize": "تغییر اندازه",
|
||||||
"backgroundOptions": "گزینههای پسزمینه",
|
"backgroundOptions": "گزینههای پسزمینه",
|
||||||
@@ -176,10 +176,10 @@
|
|||||||
"history": "تاریخچه",
|
"history": "تاریخچه",
|
||||||
"historyRestored": "نسخه بازیابی شد",
|
"historyRestored": "نسخه بازیابی شد",
|
||||||
"historyEnabled": "تاریخچه فعال شد",
|
"historyEnabled": "تاریخچه فعال شد",
|
||||||
"historyDisabledTitle": "Version history",
|
"historyDisabledTitle": "تاریخچه نسخهها",
|
||||||
"historyDisabledDesc": "تاریخچه برای حساب شما غیرفعال است.",
|
"historyDisabledDesc": "تاریخچه برای حساب شما غیرفعال است.",
|
||||||
"historyEnabledTitle": "History enabled!",
|
"historyEnabledTitle": "تاریخچه فعال شد!",
|
||||||
"historyEnabledDesc": "Versions of this note will now be recorded.",
|
"historyEnabledDesc": "نسخههای این یادداشت از این پس ثبت خواهد شد.",
|
||||||
"enableHistory": "فعالسازی تاریخچه",
|
"enableHistory": "فعالسازی تاریخچه",
|
||||||
"historyEmpty": "نسخهای موجود نیست",
|
"historyEmpty": "نسخهای موجود نیست",
|
||||||
"historySelectVersion": "نسخهای را برای پیشنمایش انتخاب کنید",
|
"historySelectVersion": "نسخهای را برای پیشنمایش انتخاب کنید",
|
||||||
@@ -188,32 +188,37 @@
|
|||||||
"sortDateAsc": "تاریخ (قدیمیترین)",
|
"sortDateAsc": "تاریخ (قدیمیترین)",
|
||||||
"sortTitleAsc": "عنوان الف ← ی",
|
"sortTitleAsc": "عنوان الف ← ی",
|
||||||
"sortTitleDesc": "عنوان ی ← الف",
|
"sortTitleDesc": "عنوان ی ← الف",
|
||||||
"suggestTitle": "AI title",
|
"suggestTitle": "عنوان هوش مصنوعی",
|
||||||
"generateTitleFromImage": "Generate title from image",
|
"generateTitleFromImage": "تولید عنوان از تصویر",
|
||||||
"titleGenerated": "Title generated",
|
"titleGenerated": "عنوان تولید شد",
|
||||||
"content": "Content",
|
"content": "محتوا",
|
||||||
"restore": "Restore",
|
"restore": "بازیابی",
|
||||||
"createFailed": "Failed to create note",
|
"createFailed": "ایجاد یادداشت ناموفق بود",
|
||||||
"updateFailed": "Failed to update note",
|
"updateFailed": "بهروزرسانی یادداشت ناموفق بود",
|
||||||
"archived": "Note archived",
|
"archived": "یادداشت بایگانی شد",
|
||||||
"archiveFailed": "Failed to archive",
|
"archiveFailed": "بایگانی ناموفق بود",
|
||||||
"sort": "Sort",
|
"sort": "مرتبسازی",
|
||||||
"confirmDeleteTitle": "Delete note",
|
"confirmDeleteTitle": "حذف یادداشت",
|
||||||
"leftShare": "Share removed",
|
"leftShare": "اشتراکگذاری حذف شد",
|
||||||
"dismissed": "Note dismissed from recent",
|
"dismissed": "یادداشت از اخیرها حذف شد",
|
||||||
"generalNotes": "General Notes",
|
"generalNotes": "یادداشتهای عمومی",
|
||||||
"noteType": "نوع یادداشت",
|
"noteType": "نوع یادداشت",
|
||||||
"typeText": "متن",
|
"typeText": "متن",
|
||||||
"typeMarkdown": "مارکداون",
|
"typeMarkdown": "مارکداون",
|
||||||
"typeRichText": "متن غنی",
|
"typeRichText": "متن غنی",
|
||||||
"typeChecklist": "چکلیست",
|
"typeChecklist": "چکلیست",
|
||||||
"convertedToRichText": "Converted to rich text",
|
"convertedToRichText": "به متن غنی تبدیل شد",
|
||||||
"conversionFailed": "Conversion failed, staying in Markdown",
|
"conversionFailed": "تبدیل ناموفق بود، در مارکداون باقی میماند",
|
||||||
"richTextPlaceholder": "یادداشتی بنویسید...",
|
"richTextPlaceholder": "یادداشتی بنویسید...",
|
||||||
"switchTypeTitle": "نوع یادداشت تغییر کند؟",
|
"switchTypeTitle": "نوع یادداشت تغییر کند؟",
|
||||||
"switchTypeWarning": "هنگام تغییر به {type} ممکن است برخی قالببندیها از بین بروند.",
|
"switchTypeWarning": "هنگام تغییر به {type} ممکن است برخی قالببندیها از بین بروند.",
|
||||||
"switchTypeContentPreserved": "محتوای شما به عنوان متن ساده حفظ میشود.",
|
"switchTypeContentPreserved": "محتوای شما به عنوان متن ساده حفظ میشود.",
|
||||||
"switchType": "تغییر به {type}"
|
"switchType": "تغییر به {type}",
|
||||||
|
"deleteVersionDesc": "این عمل قابل بازگشت نیست. این نسخه برای همیشه از تاریخچه حذف خواهد شد.",
|
||||||
|
"currentVersion": "فعلی",
|
||||||
|
"compareVersions": "مقایسه",
|
||||||
|
"diffTitle": "مقایسه",
|
||||||
|
"diffSelectHint": "برای مقایسه روی ۲ نسخه در لیست کلیک کنید"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@@ -221,35 +226,35 @@
|
|||||||
"next": "→"
|
"next": "→"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"title": "Labels",
|
"title": "برچسبها",
|
||||||
"filter": "Filter by Label",
|
"filter": "فیلتر بر اساس برچسب",
|
||||||
"manage": "Manage Labels",
|
"manage": "مدیریت برچسبها",
|
||||||
"manageTooltip": "Manage Labels",
|
"manageTooltip": "مدیریت برچسبها",
|
||||||
"changeColor": "تغییر رنگ",
|
"changeColor": "تغییر رنگ",
|
||||||
"changeColorTooltip": "تغییر رنگ",
|
"changeColorTooltip": "تغییر رنگ",
|
||||||
"delete": "Delete",
|
"delete": "حذف",
|
||||||
"deleteTooltip": "Delete label",
|
"deleteTooltip": "حذف برچسب",
|
||||||
"confirmDelete": "آیا مطمئن هستید که میخواهید این برچسب را حذف کنید؟",
|
"confirmDelete": "آیا مطمئن هستید که میخواهید این برچسب را حذف کنید؟",
|
||||||
"newLabelPlaceholder": "Create new label",
|
"newLabelPlaceholder": "ایجاد برچسب جدید",
|
||||||
"namePlaceholder": "Enter label name",
|
"namePlaceholder": "نام برچسب را وارد کنید",
|
||||||
"addLabel": "افزودن برچسب",
|
"addLabel": "افزودن برچسب",
|
||||||
"createLabel": "Create label",
|
"createLabel": "ایجاد برچسب",
|
||||||
"labelName": "Label name",
|
"labelName": "نام برچسب",
|
||||||
"labelColor": "Label color",
|
"labelColor": "رنگ برچسب",
|
||||||
"manageLabels": "Manage labels",
|
"manageLabels": "مدیریت برچسبها",
|
||||||
"manageLabelsDescription": "Add or remove labels for this note. Click on a label to change its color.",
|
"manageLabelsDescription": "برچسبهای این یادداشت را اضافه یا حذف کنید. برای تغییر رنگ روی برچسب کلیک کنید.",
|
||||||
"selectedLabels": "Selected Labels",
|
"selectedLabels": "برچسبهای انتخاب شده",
|
||||||
"allLabels": "همه برچسبها",
|
"allLabels": "همه برچسبها",
|
||||||
"clearAll": "پاک کردن همه",
|
"clearAll": "پاک کردن همه",
|
||||||
"filterByLabel": "Filter by label",
|
"filterByLabel": "فیلتر بر اساس برچسب",
|
||||||
"tagAdded": "Tag \"{tag}\" added",
|
"tagAdded": "برچسب «{tag}» اضافه شد",
|
||||||
"showLess": "Show less",
|
"showLess": "نمایش کمتر",
|
||||||
"showMore": "Show more",
|
"showMore": "نمایش بیشتر",
|
||||||
"editLabels": "Edit Labels",
|
"editLabels": "ویرایش برچسبها",
|
||||||
"editLabelsDescription": "Create, edit colors, or delete labels.",
|
"editLabelsDescription": "ایجاد، تغییر رنگ یا حذف برچسبها.",
|
||||||
"noLabelsFound": "No labels found.",
|
"noLabelsFound": "برچسبی یافت نشد.",
|
||||||
"loading": "Loading...",
|
"loading": "در حال بارگذاری...",
|
||||||
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.",
|
"notebookRequired": "⚠️ برچسبها فقط در دفترچهها موجود هستند. ابتدا این یادداشت را به یک دفترچه منتقل کنید.",
|
||||||
"count": "{count} برچسب",
|
"count": "{count} برچسب",
|
||||||
"noLabels": "بدون برچسب",
|
"noLabels": "بدون برچسب",
|
||||||
"confirmDeleteShort": "تأیید؟",
|
"confirmDeleteShort": "تأیید؟",
|
||||||
@@ -417,14 +422,14 @@
|
|||||||
"transformationsDesc": "تبدیلها — مستقیماً در یادداشت اعمال میشوند",
|
"transformationsDesc": "تبدیلها — مستقیماً در یادداشت اعمال میشوند",
|
||||||
"writeMinWordsAction": "حداقل ۵ کلمه بنویسید تا عملیات هوش مصنوعی فعال شود.",
|
"writeMinWordsAction": "حداقل ۵ کلمه بنویسید تا عملیات هوش مصنوعی فعال شود.",
|
||||||
"processingAction": "در حال پردازش...",
|
"processingAction": "در حال پردازش...",
|
||||||
"noImagesError": "No images in this note",
|
"noImagesError": "تصویری در این یادداشت وجود ندارد",
|
||||||
"overview": "Overview",
|
"overview": "نمای کلی",
|
||||||
"action": {
|
"action": {
|
||||||
"clarify": "روشن کردن",
|
"clarify": "روشن کردن",
|
||||||
"shorten": "خلاصه کردن",
|
"shorten": "خلاصه کردن",
|
||||||
"improve": "بهبود",
|
"improve": "بهبود",
|
||||||
"toMarkdown": "به مارکداون",
|
"toMarkdown": "به مارکداون",
|
||||||
"describeImages": "Describe images"
|
"describeImages": "توصیف تصاویر"
|
||||||
},
|
},
|
||||||
"openAssistant": "باز کردن دستیار هوش مصنوعی",
|
"openAssistant": "باز کردن دستیار هوش مصنوعی",
|
||||||
"poweredByMomento": "پشتیبانی شده توسط Momento AI",
|
"poweredByMomento": "پشتیبانی شده توسط Momento AI",
|
||||||
@@ -440,8 +445,50 @@
|
|||||||
"insightsTab": "بینشها",
|
"insightsTab": "بینشها",
|
||||||
"aiCopilot": "دستیار هوشمند",
|
"aiCopilot": "دستیار هوشمند",
|
||||||
"suggestTitle": "پیشنهاد عنوان با هوش مصنوعی",
|
"suggestTitle": "پیشنهاد عنوان با هوش مصنوعی",
|
||||||
"generateTitleFromImage": "Generate title from image",
|
"generateTitleFromImage": "تولید عنوان از تصویر",
|
||||||
"titleGenerated": "Title generated from image"
|
"titleGenerated": "عنوان از تصویر تولید شد",
|
||||||
|
"wordCountMin": "حداقل {min} کلمه برای بازنویسی انتخاب کنید (فعلاً {current} کلمه)",
|
||||||
|
"wordCountMax": "حداکثر {max} کلمه برای بازنویسی انتخاب کنید (فعلاً {current} کلمه)",
|
||||||
|
"resourceTab": "منبع",
|
||||||
|
"aiNoteTitle": "یادداشت هوش مصنوعی",
|
||||||
|
"injectReplace": "جایگزینی",
|
||||||
|
"injectReplaceTitle": "محتوای یادداشت را با این پیام جایگزین کنید",
|
||||||
|
"injectComplete": "تکمیل",
|
||||||
|
"injectCompleteTitle": "یادداشت را با این پیام تکمیل کنید (هوش مصنوعی)",
|
||||||
|
"injectMerge": "ادغام",
|
||||||
|
"injectMergeTitle": "با یادداشت ادغام کنید (هوش مصنوعی)",
|
||||||
|
"imagesCount": "{count} تصویر",
|
||||||
|
"resource": {
|
||||||
|
"failedToLoadUrl": "بارگذاری این URL ناموفق بود",
|
||||||
|
"pageLoaded": "صفحه بارگذاری شد: {title}",
|
||||||
|
"pageLoadError": "خطا در بارگذاری صفحه",
|
||||||
|
"pasteOrUrlFirst": "ابتدا متن را بچسبانید یا یک URL بارگذاری کنید",
|
||||||
|
"enrichError": "خطا در غنیسازی",
|
||||||
|
"enrichErrorShort": "خطای غنیسازی",
|
||||||
|
"contentApplied": "محتوا در یادداشت اعمال شد ✓",
|
||||||
|
"fromChat": "💬 از چت",
|
||||||
|
"replacement": "↓ جایگزینی",
|
||||||
|
"completedByAI": "✦ تکمیل شده توسط هوش مصنوعی",
|
||||||
|
"mergedByAI": "⟳ ادغام شده توسط هوش مصنوعی",
|
||||||
|
"rendered": "رندر شده",
|
||||||
|
"cancel": "لغو",
|
||||||
|
"applyToNote": "اعمال در یادداشت",
|
||||||
|
"urlLabel": "URL (اختیاری)",
|
||||||
|
"resourceText": "متن منبع",
|
||||||
|
"resourcePlaceholder": "متن خود را اینجا بچسبانید (مارکداون، HTML، متن ساده…)",
|
||||||
|
"words": "کلمه",
|
||||||
|
"integrationMode": "حالت یکپارچهسازی",
|
||||||
|
"modeReplace": "جایگزینی",
|
||||||
|
"modeReplaceDesc": "مستقیم، بدون هوش مصنوعی",
|
||||||
|
"modeComplete": "تکمیل",
|
||||||
|
"modeCompleteDesc": "بدون بازنویسی اضافه میکند",
|
||||||
|
"modeMerge": "ادغام",
|
||||||
|
"modeMergeDesc": "بازنویسی و یکپارچه میکند",
|
||||||
|
"aiProcessing": "هوش مصنوعی در حال پردازش…",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
|
"generatePreview": "تولید پیشنمایش",
|
||||||
|
"emptyNoteHint": "💡 یادداشت خالی است — محتوای منبع مستقیماً یکپارچه خواهد شد."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"titleSuggestions": {
|
"titleSuggestions": {
|
||||||
"available": "پیشنهادات عنوان",
|
"available": "پیشنهادات عنوان",
|
||||||
@@ -539,10 +586,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notification": {
|
"notification": {
|
||||||
"accept": "Accept",
|
"accept": "پذیرش",
|
||||||
"accepted": "Share accepted",
|
"accepted": "اشتراک پذیرفته شد",
|
||||||
"decline": "Decline",
|
"decline": "رد کردن",
|
||||||
"noNotifications": "No new notifications",
|
"noNotifications": "اعلان جدیدی نیست",
|
||||||
"shared": "\"{title}\" به اشتراک گذاشته شد",
|
"shared": "\"{title}\" به اشتراک گذاشته شد",
|
||||||
"untitled": "بدون عنوان",
|
"untitled": "بدون عنوان",
|
||||||
"notifications": "اعلانها",
|
"notifications": "اعلانها",
|
||||||
@@ -603,11 +650,11 @@
|
|||||||
"about": "درباره",
|
"about": "درباره",
|
||||||
"version": "نسخه",
|
"version": "نسخه",
|
||||||
"settingsSaved": "تنظیمات ذخیره شد",
|
"settingsSaved": "تنظیمات ذخیره شد",
|
||||||
"cardSizeMode": "Note Size",
|
"cardSizeMode": "اندازه یادداشت",
|
||||||
"cardSizeModeDescription": "Choose between variable sizes or uniform size",
|
"cardSizeModeDescription": "انتخاب بین اندازه متغیر یا یکنواخت",
|
||||||
"selectCardSizeMode": "Select display mode",
|
"selectCardSizeMode": "انتخاب حالت نمایش",
|
||||||
"cardSizeVariable": "Variable sizes (small/medium/large)",
|
"cardSizeVariable": "اندازه متغیر (کوچک/متوسط/بزرگ)",
|
||||||
"cardSizeUniform": "Uniform size",
|
"cardSizeUniform": "اندازه یکنواخت",
|
||||||
"settingsError": "خطا در ذخیره تنظیمات",
|
"settingsError": "خطا در ذخیره تنظیمات",
|
||||||
"maintenance": "نگهداری",
|
"maintenance": "نگهداری",
|
||||||
"maintenanceDescription": "ابزارهایی برای حفظ سلامت پایگاه داده",
|
"maintenanceDescription": "ابزارهایی برای حفظ سلامت پایگاه داده",
|
||||||
@@ -695,7 +742,15 @@
|
|||||||
"providerDesc": "فروشنده هوش مصنوعی مورد نظر خود را انتخاب کنید",
|
"providerDesc": "فروشنده هوش مصنوعی مورد نظر خود را انتخاب کنید",
|
||||||
"providerAutoDesc": "Ollama در صورت وجود، در غیر این صورت OpenAI",
|
"providerAutoDesc": "Ollama در صورت وجود، در غیر این صورت OpenAI",
|
||||||
"providerOllamaDesc": "۱۰۰٪ خصوصی، به صورت محلی اجرا میشود",
|
"providerOllamaDesc": "۱۰۰٪ خصوصی، به صورت محلی اجرا میشود",
|
||||||
"providerOpenAIDesc": "دقیقترین، نیاز به کلید API دارد"
|
"providerOpenAIDesc": "دقیقترین، نیاز به کلید API دارد",
|
||||||
|
"aiNote": "یادداشت هوش مصنوعی",
|
||||||
|
"aiNoteDesc": "فعالسازی دکمه چت هوش مصنوعی و ابزارهای بهبود متن",
|
||||||
|
"languageDetection": "تشخیص زبان",
|
||||||
|
"languageDetectionDesc": "به طور خودکار زبان یادداشتهای شما را تشخیص میدهد",
|
||||||
|
"autoLabeling": "پیشنهاد برچسب",
|
||||||
|
"autoLabelingDesc": "به طور خودکار برچسبها را به یادداشتهای شما پیشنهاد و اعمال میکند",
|
||||||
|
"noteHistory": "تاریخچه یادداشت",
|
||||||
|
"noteHistoryDesc": "فعالسازی اسنپشات نسخهها و بازیابی از تاریخچه"
|
||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"loading": "در حال بارگذاری...",
|
"loading": "در حال بارگذاری...",
|
||||||
@@ -752,7 +807,9 @@
|
|||||||
"markDone": "علامتگذاری به عنوان انجام شده",
|
"markDone": "علامتگذاری به عنوان انجام شده",
|
||||||
"markUndone": "علامتگذاری به عنوان انجام نشده",
|
"markUndone": "علامتگذاری به عنوان انجام نشده",
|
||||||
"todayAt": "امروز ساعت {time}",
|
"todayAt": "امروز ساعت {time}",
|
||||||
"tomorrowAt": "فردا ساعت {time}"
|
"tomorrowAt": "فردا ساعت {time}",
|
||||||
|
"clearCompleted": "پاک کردن تکمیل شدهها",
|
||||||
|
"viewAll": "مشاهده همه یادآوریها"
|
||||||
},
|
},
|
||||||
"notebook": {
|
"notebook": {
|
||||||
"create": "ایجاد دفترچه",
|
"create": "ایجاد دفترچه",
|
||||||
@@ -783,7 +840,7 @@
|
|||||||
"confidence": "اطمینان",
|
"confidence": "اطمینان",
|
||||||
"savingReminder": "شکست در ذخیره یادآوری",
|
"savingReminder": "شکست در ذخیره یادآوری",
|
||||||
"removingReminder": "شکست در حذف یادآوری",
|
"removingReminder": "شکست در حذف یادآوری",
|
||||||
"generatingDescription": "Please wait..."
|
"generatingDescription": "لطفاً صبر کنید..."
|
||||||
},
|
},
|
||||||
"notebookSuggestion": {
|
"notebookSuggestion": {
|
||||||
"title": "انتقال به {name}؟",
|
"title": "انتقال به {name}؟",
|
||||||
@@ -896,14 +953,14 @@
|
|||||||
"description": "پیکربندی ارسال ایمیل برای اعلانهای عامل و بازنشانی رمز عبور.",
|
"description": "پیکربندی ارسال ایمیل برای اعلانهای عامل و بازنشانی رمز عبور.",
|
||||||
"provider": "ارائهدهنده ایمیل",
|
"provider": "ارائهدهنده ایمیل",
|
||||||
"saveSettings": "ذخیره تنظیمات ایمیل",
|
"saveSettings": "ذخیره تنظیمات ایمیل",
|
||||||
"status": "Service Status",
|
"status": "وضعیت سرویس",
|
||||||
"keySet": "key configured",
|
"keySet": "کلید پیکربندی شده",
|
||||||
"activeAuto": "Auto mode: Resend will be used first, SMTP as fallback.",
|
"activeAuto": "حالت خودکار: ابتدا Resend استفاده میشود، SMTP به عنوان جایگزین.",
|
||||||
"activeSmtp": "Auto mode: SMTP will be used (Resend not configured).",
|
"activeSmtp": "حالت خودکار: SMTP استفاده میشود (Resend پیکربندی نشده).",
|
||||||
"noneConfigured": "No email service configured. Set up Resend or SMTP.",
|
"noneConfigured": "هیچ سرویس ایمیلی پیکربندی نشده. Resend یا SMTP را تنظیم کنید.",
|
||||||
"activeProvider": "Active provider",
|
"activeProvider": "ارائهدهنده فعال",
|
||||||
"testOk": "test passed",
|
"testOk": "تست موفق",
|
||||||
"testFail": "test failed"
|
"testFail": "تست ناموفق"
|
||||||
},
|
},
|
||||||
"smtp": {
|
"smtp": {
|
||||||
"title": "پیکربندی SMTP",
|
"title": "پیکربندی SMTP",
|
||||||
@@ -1182,7 +1239,7 @@
|
|||||||
"notesViewLabel": "چیدمان یادداشتها",
|
"notesViewLabel": "چیدمان یادداشتها",
|
||||||
"notesViewTabs": "زبانهها (سبک OneNote)",
|
"notesViewTabs": "زبانهها (سبک OneNote)",
|
||||||
"notesViewMasonry": "کارتها (شبکهای)",
|
"notesViewMasonry": "کارتها (شبکهای)",
|
||||||
"selectTheme": "Select theme",
|
"selectTheme": "انتخاب تم",
|
||||||
"fontFamilyLabel": "خانواده فونت",
|
"fontFamilyLabel": "خانواده فونت",
|
||||||
"fontFamilyDescription": "فونت استفاده شده در سراسر برنامه را انتخاب کنید",
|
"fontFamilyDescription": "فونت استفاده شده در سراسر برنامه را انتخاب کنید",
|
||||||
"selectFontFamily": "Inter برای خوانایی بهینه شده است، سیستم از فونت بومی سیستمعامل شما استفاده میکند",
|
"selectFontFamily": "Inter برای خوانایی بهینه شده است، سیستم از فونت بومی سیستمعامل شما استفاده میکند",
|
||||||
@@ -1376,10 +1433,10 @@
|
|||||||
"subtitle": "خودکارسازی وظایف پایش و تحقیق شما",
|
"subtitle": "خودکارسازی وظایف پایش و تحقیق شما",
|
||||||
"newAgent": "عامل جدید",
|
"newAgent": "عامل جدید",
|
||||||
"myAgents": "عاملهای من",
|
"myAgents": "عاملهای من",
|
||||||
"searchPlaceholder": "Search agents...",
|
"searchPlaceholder": "جستجوی عاملها...",
|
||||||
"filterAll": "All",
|
"filterAll": "همه",
|
||||||
"newBadge": "New",
|
"newBadge": "جدید",
|
||||||
"noResults": "No agents match your search.",
|
"noResults": "هیچ عاملی با جستجوی شما مطابقت ندارد.",
|
||||||
"noAgents": "بدون عامل",
|
"noAgents": "بدون عامل",
|
||||||
"noAgentsDescription": "اولین عامل خود را بسازید یا قالب زیر را نصب کنید تا وظایف پایش خود را خودکار کنید.",
|
"noAgentsDescription": "اولین عامل خود را بسازید یا قالب زیر را نصب کنید تا وظایف پایش خود را خودکار کنید.",
|
||||||
"types": {
|
"types": {
|
||||||
@@ -1423,8 +1480,8 @@
|
|||||||
"researchTopicPlaceholder": "مثال: آخرین پیشرفتها در هوش مصنوعی",
|
"researchTopicPlaceholder": "مثال: آخرین پیشرفتها در هوش مصنوعی",
|
||||||
"notifyEmail": "اعلان ایمیل",
|
"notifyEmail": "اعلان ایمیل",
|
||||||
"notifyEmailHint": "پس از هر اجرا، ایمیل حاوی نتایج عامل دریافت کنید",
|
"notifyEmailHint": "پس از هر اجرا، ایمیل حاوی نتایج عامل دریافت کنید",
|
||||||
"includeImages": "Include images",
|
"includeImages": "شامل تصاویر",
|
||||||
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
|
"includeImagesHint": "استخراج تصاویر از صفحات استخراج شده و پیوست به یادداشت تولید شده"
|
||||||
},
|
},
|
||||||
"frequencies": {
|
"frequencies": {
|
||||||
"manual": "دستی",
|
"manual": "دستی",
|
||||||
@@ -1434,19 +1491,19 @@
|
|||||||
"monthly": "ماهانه"
|
"monthly": "ماهانه"
|
||||||
},
|
},
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"nextRun": "Next run",
|
"nextRun": "اجرای بعدی",
|
||||||
"pending": "Pending trigger",
|
"pending": "در انتظار راهاندازی",
|
||||||
"time": "Time",
|
"time": "زمان",
|
||||||
"dayOfWeek": "Day of week",
|
"dayOfWeek": "روز هفته",
|
||||||
"dayOfMonth": "Day of month",
|
"dayOfMonth": "روز ماه",
|
||||||
"days": {
|
"days": {
|
||||||
"mon": "Monday",
|
"mon": "دوشنبه",
|
||||||
"tue": "Tuesday",
|
"tue": "سهشنبه",
|
||||||
"wed": "Wednesday",
|
"wed": "چهارشنبه",
|
||||||
"thu": "Thursday",
|
"thu": "پنجشنبه",
|
||||||
"fri": "Friday",
|
"fri": "جمعه",
|
||||||
"sat": "Saturday",
|
"sat": "شنبه",
|
||||||
"sun": "Sunday"
|
"sun": "یکشنبه"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
@@ -1478,8 +1535,8 @@
|
|||||||
"installSuccess": "\"{name}\" نصب شد",
|
"installSuccess": "\"{name}\" نصب شد",
|
||||||
"installError": "خطا در حین نصب",
|
"installError": "خطا در حین نصب",
|
||||||
"saveError": "خطا در ذخیره",
|
"saveError": "خطا در ذخیره",
|
||||||
"autoRunSuccess": "Agent \"{name}\" executed automatically with success",
|
"autoRunSuccess": "عامل «{name}» با موفقیت به صورت خودکار اجرا شد",
|
||||||
"autoRunError": "Agent \"{name}\" failed during automatic execution"
|
"autoRunError": "عامل «{name}» در اجرای خودکار شکست خورد"
|
||||||
},
|
},
|
||||||
"templates": {
|
"templates": {
|
||||||
"title": "قالبها",
|
"title": "قالبها",
|
||||||
@@ -1593,7 +1650,7 @@
|
|||||||
"searching": "در حال جستجو...",
|
"searching": "در حال جستجو...",
|
||||||
"noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
"noNotesFoundForContext": "هیچ یادداشت مرتبطی برای این سوال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||||
"webSearch": "جستجوی وب",
|
"webSearch": "جستجوی وب",
|
||||||
"timeoutWarning": "Response is taking longer than expected..."
|
"timeoutWarning": "پاسخ بیشتر از حد انتظار طول میکشد..."
|
||||||
},
|
},
|
||||||
"labHeader": {
|
"labHeader": {
|
||||||
"title": "آزمایشگاه",
|
"title": "آزمایشگاه",
|
||||||
@@ -1611,10 +1668,81 @@
|
|||||||
"deleteSpace": "حذف فضا",
|
"deleteSpace": "حذف فضا",
|
||||||
"deleted": "فضا حذف شد",
|
"deleted": "فضا حذف شد",
|
||||||
"deleteError": "خطا در حذف",
|
"deleteError": "خطا در حذف",
|
||||||
"rename": "Rename"
|
"rename": "تغییر نام"
|
||||||
},
|
},
|
||||||
"lab": {
|
"lab": {
|
||||||
"initializing": "راهاندازی فضای کاری",
|
"initializing": "راهاندازی فضای کاری",
|
||||||
"loadingIdeas": "بارگذاری ایدههای شما..."
|
"loadingIdeas": "بارگذاری ایدههای شما..."
|
||||||
|
},
|
||||||
|
"richTextEditor": {
|
||||||
|
"slashHint": "↑↓ ناوبری · Enter درج · Tab تغییر بخش",
|
||||||
|
"slashLoading": "هوش مصنوعی در حال فکر کردن...",
|
||||||
|
"slashTabAll": "همه",
|
||||||
|
"slashCatBasic": "بلوکهای پایه",
|
||||||
|
"slashCatMedia": "رسانه",
|
||||||
|
"slashCatFormatting": "قالببندی",
|
||||||
|
"slashCatAi": "هوش مصنوعی",
|
||||||
|
"insertImage": "درج تصویر",
|
||||||
|
"imageUrlPlaceholder": "https://example.com/image.png",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
|
"cancel": "لغو",
|
||||||
|
"insert": "درج",
|
||||||
|
"slashText": "متن",
|
||||||
|
"slashTextDesc": "پاراگراف ساده",
|
||||||
|
"slashH1": "عنوان ۱",
|
||||||
|
"slashH1Desc": "عنوان بخش بزرگ",
|
||||||
|
"slashH2": "عنوان ۲",
|
||||||
|
"slashH2Desc": "عنوان بخش متوسط",
|
||||||
|
"slashH3": "عنوان ۳",
|
||||||
|
"slashH3Desc": "عنوان بخش کوچک",
|
||||||
|
"slashBullet": "فهرست نقطهای",
|
||||||
|
"slashBulletDesc": "فهرست بدون ترتیب",
|
||||||
|
"slashNumbered": "فهرست شمارهدار",
|
||||||
|
"slashNumberedDesc": "فهرست مرتب شمارهدار",
|
||||||
|
"slashTodo": "فهرست وظایف",
|
||||||
|
"slashTodoDesc": "وظایف با چکباکس",
|
||||||
|
"slashQuote": "نقل قول",
|
||||||
|
"slashQuoteDesc": "ثبت یک نقل قول",
|
||||||
|
"slashCode": "بلوک کد",
|
||||||
|
"slashCodeDesc": "قطعه کد",
|
||||||
|
"slashDivider": "جداکننده",
|
||||||
|
"slashDividerDesc": "جداکننده افقی",
|
||||||
|
"slashImage": "تصویر",
|
||||||
|
"slashImageDesc": "درج تصویر از URL",
|
||||||
|
"slashAlignLeft": "تراز چپ",
|
||||||
|
"slashAlignLeftDesc": "تراز متن به چپ",
|
||||||
|
"slashAlignCenter": "مرکز",
|
||||||
|
"slashAlignCenterDesc": "مرکز کردن متن",
|
||||||
|
"slashAlignRight": "تراز راست",
|
||||||
|
"slashAlignRightDesc": "تراز متن به راست",
|
||||||
|
"slashSuperscript": "بالانویس",
|
||||||
|
"slashSuperscriptDesc": "متن بالاتر از خط اصلی",
|
||||||
|
"slashSubscript": "زیرنویس",
|
||||||
|
"slashSubscriptDesc": "متن پایینتر از خط اصلی",
|
||||||
|
"slashClarify": "شفافسازی",
|
||||||
|
"slashClarifyDesc": "روشنتر کردن متن",
|
||||||
|
"slashShorten": "خلاصه کردن",
|
||||||
|
"slashShortenDesc": "فشرده کردن متن",
|
||||||
|
"slashImprove": "بهبود",
|
||||||
|
"slashImproveDesc": "بهبود سبک نوشتار",
|
||||||
|
"slashExpand": "بسط دادن",
|
||||||
|
"slashExpandDesc": "توسعه و غنیسازی متن",
|
||||||
|
"imageModalTitle": "درج تصویر",
|
||||||
|
"imageModalPreview": "پیشنمایش",
|
||||||
|
"imageModalCancel": "لغو",
|
||||||
|
"imageModalInsert": "درج",
|
||||||
|
"imageModalInvalidUrl": "لطفاً یک URL معتبر وارد کنید",
|
||||||
|
"imageModalLoadFailed": "بارگذاری تصویر ناموفق بود",
|
||||||
|
"linkPlaceholder": "یک لینک بچسبانید یا تایپ کنید...",
|
||||||
|
"bold": "درشت",
|
||||||
|
"italic": "کج",
|
||||||
|
"underline": "زیرخط",
|
||||||
|
"strike": "خطخورده",
|
||||||
|
"code": "کد",
|
||||||
|
"highlight": "برجسته",
|
||||||
|
"superscript": "بالانویس",
|
||||||
|
"subscript": "زیرنویس",
|
||||||
|
"addBlock": "افزودن بلوک",
|
||||||
|
"placeholder": "برای دستورات '/' تایپ کنید..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
memento-note/scripts/add-expand-btn.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const filePath = path.join(__dirname, '..', 'components', 'notes-tabs-view.tsx');
|
||||||
|
let lines = fs.readFileSync(filePath, 'utf8').split('\r\n');
|
||||||
|
|
||||||
|
// Line 977 (index 976) closes the scrollable div
|
||||||
|
// Line 978 (index 977) closes the left panel div
|
||||||
|
// We need to:
|
||||||
|
// 1. After the scrollable </div> (index 976), add )} to close {listOpen && (...
|
||||||
|
// 2. Add expand button JSX
|
||||||
|
// 3. Then close left panel div
|
||||||
|
|
||||||
|
const expandButton = [
|
||||||
|
' )}',
|
||||||
|
' {/* Expand button shown in collapsed state */}',
|
||||||
|
' {!listOpen && (',
|
||||||
|
' <div className="flex flex-col items-center pt-3">',
|
||||||
|
' <Button',
|
||||||
|
' variant="ghost"',
|
||||||
|
' size="sm"',
|
||||||
|
' className="h-7 w-7 p-0 text-muted-foreground/70 hover:bg-primary/8 hover:text-primary"',
|
||||||
|
' onClick={() => setListOpen(true)}',
|
||||||
|
' title="Afficher la liste"',
|
||||||
|
' >',
|
||||||
|
' <PanelLeftOpen className="h-3.5 w-3.5" />',
|
||||||
|
' </Button>',
|
||||||
|
' </div>',
|
||||||
|
' )}',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Insert after index 976 (which is ' </div>' - the scrollable div close)
|
||||||
|
// and remove the original ' </div>' at index 977 (left panel close)
|
||||||
|
// then re-add left panel close
|
||||||
|
lines.splice(977, 1, ...expandButton, ' </div>');
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, lines.join('\r\n'));
|
||||||
|
console.log('Done, total lines:', lines.length);
|
||||||
60
memento-note/scripts/add-resize.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '..', 'components', 'notes-tabs-view.tsx');
|
||||||
|
let src = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
const insertCode = `
|
||||||
|
// Resizable left panel
|
||||||
|
const [listPanelWidth, setListPanelWidth] = useState(256)
|
||||||
|
const isDraggingRef = useRef(false)
|
||||||
|
|
||||||
|
const handleResizeStart = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isDraggingRef.current = true
|
||||||
|
const startX = e.clientX
|
||||||
|
const startW = listPanelWidth
|
||||||
|
const onMove = (ev) => {
|
||||||
|
if (!isDraggingRef.current) return
|
||||||
|
setListPanelWidth(Math.min(420, Math.max(180, startW + (ev.clientX - startX))))
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
isDraggingRef.current = false
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Insert before the return statement
|
||||||
|
const MARKER = ' return (\r\n <div\r\n className="flex min-h-0 flex-1 gap-0';
|
||||||
|
const ALT_MARKER = ' return (\n <div\n className="flex min-h-0 flex-1 gap-0';
|
||||||
|
|
||||||
|
if (src.includes(MARKER) && !src.includes('listPanelWidth')) {
|
||||||
|
src = src.replace(MARKER, insertCode + MARKER);
|
||||||
|
console.log('Inserted using CRLF marker');
|
||||||
|
} else if (src.includes(ALT_MARKER) && !src.includes('listPanelWidth')) {
|
||||||
|
src = src.replace(ALT_MARKER, insertCode + ALT_MARKER);
|
||||||
|
console.log('Inserted using LF marker');
|
||||||
|
} else if (src.includes('listPanelWidth')) {
|
||||||
|
console.log('State already exists, skipping insert');
|
||||||
|
} else {
|
||||||
|
console.error('Marker not found!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix left panel width - CRLF version
|
||||||
|
const OLD_CRLF = 'className="flex w-80 shrink-0 flex-col border-r border-border/60 bg-background">';
|
||||||
|
const NEW = 'className="flex shrink-0 flex-col border-r border-border/60 bg-background" style={{ width: listPanelWidth }}>';
|
||||||
|
if (src.includes(OLD_CRLF)) {
|
||||||
|
src = src.replace(OLD_CRLF, NEW);
|
||||||
|
console.log('Replaced panel width');
|
||||||
|
} else {
|
||||||
|
console.log('Panel width already updated or marker not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, src);
|
||||||
|
console.log('Done!');
|
||||||
54
memento-note/scripts/fix-chat-buttons.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const filePath = path.join(__dirname, '..', 'components', 'contextual-ai-chat.tsx');
|
||||||
|
let src = fs.readFileSync(filePath, 'utf8');
|
||||||
|
|
||||||
|
// ─── FIX 1: make inject buttons always visible on last assistant msg ──────────
|
||||||
|
// Replace the hover-gated condition with always-visible for last msg
|
||||||
|
const OLD_BUTTONS_CONDITION = ` {/* Hover-actions \u2014 visible only on assistant messages */}\r\n {isAssistant && onApplyToNote && (\r\n <div className={cn(\r\n 'flex gap-1 transition-all duration-150',\r\n isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 pointer-events-none',\r\n )}>`;
|
||||||
|
|
||||||
|
const NEW_BUTTONS_CONDITION = ` {/* Inject buttons \u2014 always visible on last assistant msg */}\r\n {isAssistant && onApplyToNote && (() => {\r\n const lastAssistantId = messages.filter(m => m.role === 'assistant').at(-1)?.id\r\n const alwaysShow = msg.id === lastAssistantId\r\n return (\r\n <div className={cn(\r\n 'flex gap-1 transition-all duration-150',\r\n (alwaysShow || isHovered) ? 'opacity-100' : 'opacity-0 pointer-events-none',\r\n )}>`;
|
||||||
|
|
||||||
|
// Also need to close the IIFE
|
||||||
|
const OLD_BUTTONS_CLOSE = ` </div>\r\n )}\r\n </div>`;
|
||||||
|
const NEW_BUTTONS_CLOSE = ` </div>\r\n )\r\n })()}\r\n </div>`;
|
||||||
|
|
||||||
|
if (src.includes(OLD_BUTTONS_CONDITION)) {
|
||||||
|
src = src.replace(OLD_BUTTONS_CONDITION, NEW_BUTTONS_CONDITION);
|
||||||
|
console.log('Fix 1a: buttons condition updated');
|
||||||
|
} else {
|
||||||
|
console.error('Fix 1a: OLD_BUTTONS_CONDITION not found!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src.includes(OLD_BUTTONS_CLOSE)) {
|
||||||
|
src = src.replace(OLD_BUTTONS_CLOSE, NEW_BUTTONS_CLOSE);
|
||||||
|
console.log('Fix 1b: buttons IIFE close updated');
|
||||||
|
} else {
|
||||||
|
console.error('Fix 1b: OLD_BUTTONS_CLOSE not found!');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FIX 2: Move preview panel OUTSIDE the scrollable div ────────────────────
|
||||||
|
// The scrollable div ends with: messagesEndRef then </div>
|
||||||
|
// We need to:
|
||||||
|
// A) Remove the preview block from inside the scrollable area (already was removed in previous sessions since it was added after messagesEndRef)
|
||||||
|
// B) Add preview as shrink-0 panel between the scroll div and controls
|
||||||
|
|
||||||
|
// Find the closing of the scrollable area + start of controls
|
||||||
|
const OLD_SCROLL_END = ` <div ref={messagesEndRef} />\r\n </div>\r\n\r\n {/* Scope & Tone Control Area */}`;
|
||||||
|
const NEW_SCROLL_END = ` <div ref={messagesEndRef} />\r\n </div>\r\n\r\n {/* \u2550\u2550 Inject Preview Panel \u2014 fixed, always visible, NO scroll needed \u2550\u2550 */}\r\n {resourceEnriching && (\r\n <div className="shrink-0 mx-3 mb-2 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-50/50 dark:bg-emerald-950/20 p-3">\r\n <Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-600 shrink-0" />\r\n <span className="text-xs text-emerald-700 dark:text-emerald-400">Traitement IA en cours...</span>\r\n </div>\r\n )}\r\n {resourcePreview && !resourceEnriching && (\r\n <div className="shrink-0 mx-3 mb-2 rounded-xl border border-primary/30 bg-primary/5 overflow-hidden">\r\n <div className="flex items-center justify-between px-3 py-2 border-b border-primary/20">\r\n <span className="text-[10px] font-semibold uppercase tracking-wider text-primary">\r\n {resourcePreview.source === 'complete' ? 'Compl\u00e9t\u00e9 par IA'\r\n : resourcePreview.source === 'merge' ? 'Fusionn\u00e9 par IA'\r\n : 'Aper\u00e7u'}\r\n </span>\r\n <button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">\r\n <X className="h-3 w-3" />\r\n </button>\r\n </div>\r\n <div className="px-3 py-2 max-h-36 overflow-y-auto text-sm">\r\n <MarkdownContent content={resourcePreview.text} />\r\n </div>\r\n <div className="flex gap-2 px-3 py-2 border-t border-primary/20">\r\n <button\r\n onClick={() => setResourcePreview(null)}\r\n className="flex-1 text-[11px] py-1.5 rounded-lg border border-border/60 text-muted-foreground hover:bg-muted transition-colors"\r\n >\r\n Annuler\r\n </button>\r\n <button\r\n onClick={() => {\r\n if (onApplyToNote && resourcePreview) {\r\n onApplyToNote(resourcePreview.text)\r\n setResourcePreview(null)\r\n setResourceText('')\r\n toast.success('Appliqu\u00e9 \u00e0 la note')\r\n }\r\n }}\r\n disabled={!onApplyToNote}\r\n className="flex-1 text-[11px] py-1.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-1"\r\n >\r\n <Check className="h-3 w-3" />\r\n Appliquer \u00e0 la note\r\n </button>\r\n </div>\r\n </div>\r\n )}\r\n\r\n {/* Scope & Tone Control Area */}`;
|
||||||
|
|
||||||
|
if (src.includes(OLD_SCROLL_END)) {
|
||||||
|
src = src.replace(OLD_SCROLL_END, NEW_SCROLL_END);
|
||||||
|
console.log('Fix 2: preview panel moved outside scroll');
|
||||||
|
} else {
|
||||||
|
console.error('Fix 2: OLD_SCROLL_END not found!');
|
||||||
|
// Show what we're looking for context
|
||||||
|
const idx = src.indexOf('messagesEndRef');
|
||||||
|
console.error('Context around messagesEndRef:', JSON.stringify(src.slice(idx-20, idx+200)));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, src);
|
||||||
|
console.log('Done!');
|
||||||