## Translation Files - Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ missing translation keys across all 15 languages - New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels - Update nav section with workspace, quickAccess, myLibrary keys ## Component Updates - Update 15+ components to use translation keys instead of hardcoded text - Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc. - Replace 80+ hardcoded English/French strings with t() calls - Ensure consistent UI across all supported languages ## Code Quality - Remove 77+ console.log statements from codebase - Clean up API routes, components, hooks, and services - Keep only essential error handling (no debugging logs) ## UI/UX Improvements - Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500) - Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items) - Make "+" button permanently visible in notebooks section - Fix grammar and syntax errors in multiple components ## Bug Fixes - Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json - Fix syntax errors in notebook-suggestion-toast.tsx - Fix syntax errors in use-auto-tagging.ts - Fix syntax errors in paragraph-refactor.service.ts - Fix duplicate "fusion" section in nl.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Ou une version plus courte si vous préférez : feat(i18n): Add 15 languages, remove logs, update UI components - Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ translation keys: notebook, pagination, AI features - Update 15+ components to use translations (80+ strings) - Remove 77+ console.log statements from codebase - Fix JSON syntax errors in 4 translation files - Fix component syntax errors (toast, hooks, services) - Update logo to yellow post-it style - Change selection colors (#FEF3C6, #EFB162) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { X, Link2, Sparkles, Edit, Check } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Note } from '@/lib/types'
|
|
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
|
|
|
interface FusionModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
notes: Array<Partial<Note>>
|
|
onConfirmFusion: (mergedNote: { title: string; content: string }, options: FusionOptions) => Promise<void>
|
|
}
|
|
|
|
interface FusionOptions {
|
|
archiveOriginals: boolean
|
|
keepAllTags: boolean
|
|
useLatestTitle: boolean
|
|
createBacklinks: boolean
|
|
}
|
|
|
|
export function FusionModal({
|
|
isOpen,
|
|
onClose,
|
|
notes,
|
|
onConfirmFusion
|
|
}: FusionModalProps) {
|
|
const { t } = useLanguage()
|
|
const [selectedNoteIds, setSelectedNoteIds] = useState<string[]>(notes.filter(n => n.id).map(n => n.id!))
|
|
const [customPrompt, setCustomPrompt] = useState('')
|
|
const [fusionPreview, setFusionPreview] = useState('')
|
|
const [isGenerating, setIsGenerating] = useState(false)
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [generationError, setGenerationError] = useState<string | null>(null)
|
|
const hasGeneratedRef = useRef(false)
|
|
|
|
const [options, setOptions] = useState<FusionOptions>({
|
|
archiveOriginals: true,
|
|
keepAllTags: true,
|
|
useLatestTitle: false,
|
|
createBacklinks: false
|
|
})
|
|
|
|
const handleGenerateFusion = useCallback(async () => {
|
|
setIsGenerating(true)
|
|
setGenerationError(null)
|
|
setFusionPreview('')
|
|
|
|
try {
|
|
|
|
// Call AI API to generate fusion
|
|
const res = await fetch('/api/ai/echo/fusion', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
noteIds: selectedNoteIds,
|
|
prompt: customPrompt
|
|
})
|
|
})
|
|
|
|
const data = await res.json()
|
|
|
|
if (!res.ok) {
|
|
throw new Error(data.error || 'Failed to generate fusion')
|
|
}
|
|
|
|
if (!data.fusedNote) {
|
|
throw new Error('No fusion content returned from API')
|
|
}
|
|
|
|
setFusionPreview(data.fusedNote)
|
|
} catch (error) {
|
|
console.error('[FusionModal] Failed to generate:', error)
|
|
const errorMessage = error instanceof Error ? error.message : t('memoryEcho.fusion.generateError')
|
|
setGenerationError(errorMessage)
|
|
} finally {
|
|
setIsGenerating(false)
|
|
}
|
|
}, [selectedNoteIds, customPrompt])
|
|
|
|
// Auto-generate fusion preview when modal opens with selected notes
|
|
useEffect(() => {
|
|
// Reset generation state when modal closes
|
|
if (!isOpen) {
|
|
hasGeneratedRef.current = false
|
|
setGenerationError(null)
|
|
setFusionPreview('')
|
|
return
|
|
}
|
|
|
|
// Generate only once when modal opens and we have 2+ notes
|
|
if (isOpen && selectedNoteIds.length >= 2 && !hasGeneratedRef.current && !isGenerating) {
|
|
hasGeneratedRef.current = true
|
|
handleGenerateFusion()
|
|
}
|
|
}, [isOpen, selectedNoteIds.length, isGenerating, handleGenerateFusion])
|
|
|
|
const handleConfirm = async () => {
|
|
if (isGenerating) {
|
|
return
|
|
}
|
|
|
|
if (!fusionPreview) {
|
|
await handleGenerateFusion()
|
|
return
|
|
}
|
|
|
|
setIsGenerating(true)
|
|
try {
|
|
// Parse the preview into title and content
|
|
const lines = fusionPreview.split('\n')
|
|
const title = lines[0].replace(/^#+\s*/, '').trim()
|
|
const content = lines.slice(1).join('\n').trim()
|
|
|
|
await onConfirmFusion(
|
|
{ title, content },
|
|
options
|
|
)
|
|
|
|
onClose()
|
|
} finally {
|
|
setIsGenerating(false)
|
|
}
|
|
}
|
|
|
|
const selectedNotes = notes.filter(n => n.id && selectedNoteIds.includes(n.id))
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-0">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
|
<Link2 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-xl font-semibold">
|
|
{t('memoryEcho.fusion.title')}
|
|
</h2>
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
{t('memoryEcho.fusion.mergeNotes', { count: selectedNoteIds.length })}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
|
>
|
|
<X className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* Section 1: Note Selection */}
|
|
<div className="p-6 border-b dark:border-zinc-700">
|
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
|
{t('memoryEcho.fusion.notesToMerge')}
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{notes.filter(n => n.id).map((note) => (
|
|
<div
|
|
key={note.id}
|
|
className={cn(
|
|
"flex items-start gap-3 p-3 rounded-lg border transition-colors",
|
|
selectedNoteIds.includes(note.id!)
|
|
? "border-purple-200 bg-purple-50 dark:bg-purple-950/20 dark:border-purple-800"
|
|
: "border-gray-200 dark:border-zinc-700 opacity-50"
|
|
)}
|
|
>
|
|
<Checkbox
|
|
id={`note-${note.id}`}
|
|
checked={selectedNoteIds.includes(note.id!)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked && note.id) {
|
|
setSelectedNoteIds([...selectedNoteIds, note.id])
|
|
} else if (note.id) {
|
|
setSelectedNoteIds(selectedNoteIds.filter(id => id !== note.id))
|
|
}
|
|
}}
|
|
/>
|
|
<label
|
|
htmlFor={`note-${note.id}`}
|
|
className="flex-1 cursor-pointer"
|
|
>
|
|
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
|
|
{note.title || t('memoryEcho.comparison.untitled')}
|
|
</div>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{note.createdAt ? new Date(note.createdAt).toLocaleDateString() : t('memoryEcho.fusion.unknownDate')}
|
|
</div>
|
|
</label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section 2: Custom Prompt (Optional) */}
|
|
<div className="p-6 border-b dark:border-zinc-700">
|
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
|
{t('memoryEcho.fusion.optionalPrompt')}
|
|
</h3>
|
|
<Textarea
|
|
placeholder={t('memoryEcho.fusion.promptPlaceholder')}
|
|
value={customPrompt}
|
|
onChange={(e) => setCustomPrompt(e.target.value)}
|
|
rows={3}
|
|
className="resize-none"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="mt-2"
|
|
onClick={handleGenerateFusion}
|
|
disabled={isGenerating || selectedNoteIds.length < 2}
|
|
>
|
|
{isGenerating ? (
|
|
<>
|
|
<Sparkles className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('memoryEcho.fusion.generating')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Sparkles className="h-4 w-4 mr-2" />
|
|
{t('memoryEcho.fusion.generateFusion')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{generationError && (
|
|
<div className="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<p className="text-sm text-red-700 dark:text-red-400">
|
|
{t('memoryEcho.fusion.error')}: {generationError}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Section 3: Preview */}
|
|
{fusionPreview && (
|
|
<div className="p-6 border-b dark:border-zinc-700">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
{t('memoryEcho.fusion.previewTitle')}
|
|
</h3>
|
|
{!isEditing && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setIsEditing(true)}
|
|
>
|
|
<Edit className="h-4 w-4 mr-2" />
|
|
{t('memoryEcho.fusion.modify')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{isEditing ? (
|
|
<Textarea
|
|
value={fusionPreview}
|
|
onChange={(e) => setFusionPreview(e.target.value)}
|
|
rows={10}
|
|
className="resize-none font-mono text-sm"
|
|
/>
|
|
) : (
|
|
<div className="border dark:border-zinc-700 rounded-lg p-4 bg-white dark:bg-zinc-900">
|
|
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans">
|
|
{fusionPreview}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Section 4: Options */}
|
|
<div className="p-6">
|
|
<h3 className="text-sm font-semibold mb-3">{t('memoryEcho.fusion.optionsTitle')}</h3>
|
|
<div className="space-y-2">
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<Checkbox
|
|
checked={options.archiveOriginals}
|
|
onCheckedChange={(checked) =>
|
|
setOptions({ ...options, archiveOriginals: !!checked })
|
|
}
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('memoryEcho.fusion.archiveOriginals')}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<Checkbox
|
|
checked={options.keepAllTags}
|
|
onCheckedChange={(checked) =>
|
|
setOptions({ ...options, keepAllTags: !!checked })
|
|
}
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('memoryEcho.fusion.keepAllTags')}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<Checkbox
|
|
checked={options.useLatestTitle}
|
|
onCheckedChange={(checked) =>
|
|
setOptions({ ...options, useLatestTitle: !!checked })
|
|
}
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('memoryEcho.fusion.useLatestTitle')}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-3 cursor-pointer">
|
|
<Checkbox
|
|
checked={options.createBacklinks}
|
|
onCheckedChange={(checked) =>
|
|
setOptions({ ...options, createBacklinks: !!checked })
|
|
}
|
|
/>
|
|
<span className="text-sm text-gray-700 dark:text-gray-300">
|
|
{t('memoryEcho.fusion.createBacklinks')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="p-6 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onClose}
|
|
>
|
|
{t('memoryEcho.fusion.cancel')}
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{isEditing && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsEditing(false)}
|
|
>
|
|
{t('memoryEcho.fusion.finishEditing')}
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
onClick={handleConfirm}
|
|
disabled={selectedNoteIds.length < 2 || isGenerating}
|
|
className="bg-purple-600 hover:bg-purple-700 text-white"
|
|
>
|
|
<Check className="h-4 w-4 mr-2" />
|
|
{isGenerating ? (
|
|
<>
|
|
<Sparkles className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('memoryEcho.fusion.generating')}
|
|
</>
|
|
) : (
|
|
t('memoryEcho.fusion.confirmFusion')
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|