refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
85
keep-notes/.backup-keep/favorites-section.tsx
Normal file
85
keep-notes/.backup-keep/favorites-section.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { NoteCard } from './note-card'
|
||||
import { ChevronDown, ChevronUp, Pin } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface FavoritesSectionProps {
|
||||
pinnedNotes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section data-testid="favorites-section" className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4 px-2 py-2">
|
||||
<Pin className="w-5 h-5 text-muted-foreground animate-pulse" />
|
||||
<div className="h-6 w-32 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (pinnedNotes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-testid="favorites-section" className="mb-8">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsCollapsed(!isCollapsed)
|
||||
}
|
||||
}}
|
||||
className="w-full flex items-center justify-between gap-2 mb-4 px-2 py-2 hover:bg-accent rounded-lg transition-colors min-h-[44px]"
|
||||
aria-expanded={!isCollapsed}
|
||||
aria-label={t('favorites.toggleSection') || 'Toggle pinned notes section'}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">📌</span>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{t('notes.pinnedNotes')}
|
||||
<span className="text-sm font-medium text-muted-foreground ml-2">
|
||||
({pinnedNotes.length})
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Collapsible Content */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{pinnedNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onSizeChange={(size) => onSizeChange?.(note.id, size)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
454
keep-notes/.backup-keep/home-client.tsx
Normal file
454
keep-notes/.backup-keep/home-client.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { getAllNotes, searchNotes } from '@/app/actions/notes'
|
||||
import { NoteInput } from '@/components/note-input'
|
||||
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
|
||||
import { NotesViewToggle } from '@/components/notes-view-toggle'
|
||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { FavoritesSection } from '@/components/favorites-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LabelFilter } from '@/components/label-filter'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useHomeView } from '@/context/home-view-context'
|
||||
|
||||
// Lazy-load heavy dialogs — uniquement chargés à la demande
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const BatchOrganizationDialog = dynamic(
|
||||
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
const AutoLabelSuggestionDialog = dynamic(
|
||||
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
type InitialSettings = {
|
||||
showRecentNotes: boolean
|
||||
notesViewMode: 'masonry' | 'tabs'
|
||||
}
|
||||
|
||||
interface HomeClientProps {
|
||||
/** Notes pré-chargées côté serveur — hydratées immédiatement sans loading spinner */
|
||||
initialNotes: Note[]
|
||||
initialSettings: InitialSettings
|
||||
}
|
||||
|
||||
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
|
||||
initialNotes.filter(n => n.isPinned)
|
||||
)
|
||||
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
|
||||
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
|
||||
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
|
||||
const { refreshKey } = useNoteRefresh()
|
||||
const { labels } = useLabels()
|
||||
const { setControls } = useHomeView()
|
||||
|
||||
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
|
||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldSuggestLabels && suggestNotebookId) {
|
||||
setAutoLabelOpen(true)
|
||||
}
|
||||
}, [shouldSuggestLabels, suggestNotebookId])
|
||||
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const isInbox = !notebookFilter
|
||||
|
||||
const handleNoteCreated = useCallback((note: Note) => {
|
||||
setNotes((prevNotes) => {
|
||||
const notebookFilter = searchParams.get('notebook')
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
|
||||
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
|
||||
if (!notebookFilter && note.notebookId) return prevNotes
|
||||
|
||||
if (labelFilter.length > 0) {
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes
|
||||
}
|
||||
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
const noteLabels = note.labels || []
|
||||
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) return prevNotes
|
||||
}
|
||||
|
||||
if (search) {
|
||||
router.refresh()
|
||||
return prevNotes
|
||||
}
|
||||
|
||||
const isPinned = note.isPinned || false
|
||||
const pinnedNotes = prevNotes.filter(n => n.isPinned)
|
||||
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
|
||||
|
||||
if (isPinned) {
|
||||
return [note, ...pinnedNotes, ...unpinnedNotes]
|
||||
} else {
|
||||
return [...pinnedNotes, note, ...unpinnedNotes]
|
||||
}
|
||||
})
|
||||
|
||||
if (!note.notebookId) {
|
||||
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
|
||||
if (wordCount >= 20) {
|
||||
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
|
||||
}
|
||||
}
|
||||
}, [searchParams, labels, router])
|
||||
|
||||
const handleOpenNote = (noteId: string) => {
|
||||
const note = notes.find(n => n.id === noteId)
|
||||
if (note) setEditingNote({ note, readOnly: false })
|
||||
}
|
||||
|
||||
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
|
||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||
}, [])
|
||||
|
||||
useReminderCheck(notes)
|
||||
|
||||
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
|
||||
// Les notes initiales suffisent sans filtre
|
||||
useEffect(() => {
|
||||
const search = searchParams.get('search')?.trim() || null
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
const notebook = searchParams.get('notebook')
|
||||
const semanticMode = searchParams.get('semantic') === 'true'
|
||||
|
||||
// Pour le refreshKey (mutations), toujours recharger
|
||||
// Pour les filtres, charger depuis le serveur
|
||||
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true)
|
||||
let allNotes = search
|
||||
? await searchNotes(search, semanticMode, notebook || undefined)
|
||||
: await getAllNotes()
|
||||
|
||||
// Filtre notebook côté client
|
||||
if (notebook) {
|
||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook)
|
||||
} else {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
||||
}
|
||||
|
||||
// Filtre labels
|
||||
if (labelFilter.length > 0) {
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Filtre couleur
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter((label: any) => label.color === colorFilter)
|
||||
.map((label: any) => label.name)
|
||||
allNotes = allNotes.filter((note: any) =>
|
||||
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
// Merger avec les tailles locales pour ne pas écraser les modifications
|
||||
setNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
|
||||
if (refreshKey > 0 || hasActiveFilter) {
|
||||
const cancelled = { value: false }
|
||||
load().then(() => { if (cancelled.value) return })
|
||||
return () => { cancelled.value = true }
|
||||
} else {
|
||||
// Données initiales : filtrage inbox/notebook côté client seulement
|
||||
let filtered = initialNotes
|
||||
if (notebook) {
|
||||
filtered = initialNotes.filter(n => n.notebookId === notebook)
|
||||
} else {
|
||||
filtered = initialNotes.filter(n => !n.notebookId)
|
||||
}
|
||||
// Merger avec les tailles déjà modifiées localement
|
||||
setNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
setPinnedNotes(filtered.filter(n => n.isPinned))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, refreshKey])
|
||||
|
||||
const { notebooks } = useNotebooks()
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
const [showNoteInput, setShowNoteInput] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setControls({
|
||||
isTabsMode: notesViewMode === 'tabs',
|
||||
openNoteComposer: () => setShowNoteInput(true),
|
||||
})
|
||||
return () => setControls(null)
|
||||
}, [notesViewMode, setControls])
|
||||
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
return ICON_MAP[iconName] || Folder
|
||||
}
|
||||
|
||||
const handleNoteCreatedWrapper = (note: any) => {
|
||||
handleNoteCreated(note)
|
||||
setShowNoteInput(false)
|
||||
}
|
||||
|
||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<span>{t('nav.notebooks')}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="font-medium text-primary">{notebookName}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const isTabs = notesViewMode === 'tabs'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full min-h-0 flex-1 flex-col',
|
||||
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
|
||||
)}
|
||||
>
|
||||
{/* Notebook Specific Header */}
|
||||
{currentNotebook ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
||||
)}
|
||||
>
|
||||
<Breadcrumbs notebookName={currentNotebook.name} />
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
|
||||
return (
|
||||
<Icon
|
||||
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
|
||||
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.addNote') || 'Add Note'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
|
||||
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
|
||||
)}
|
||||
>
|
||||
{!isTabs && <div className="mb-1 h-5" />}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
|
||||
<LabelFilter
|
||||
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
|
||||
onFilterChange={(newLabels) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
|
||||
else params.delete('labels')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
{isInbox && !isLoading && notes.length >= 2 && (
|
||||
<Button
|
||||
onClick={() => setBatchOrganizationOpen(true)}
|
||||
variant="outline"
|
||||
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
|
||||
title={t('batch.organizeWithAI')}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 text-purple-600" />
|
||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.newNote')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoteInput && (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-in fade-in slide-in-from-top-4 duration-300',
|
||||
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
|
||||
)}
|
||||
>
|
||||
<NoteInput
|
||||
onNoteCreated={handleNoteCreatedWrapper}
|
||||
forceExpanded={true}
|
||||
fullWidth={isTabs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
||||
) : (
|
||||
<>
|
||||
<FavoritesSection
|
||||
pinnedNotes={pinnedNotes}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
/>
|
||||
|
||||
{notes.filter((note) => !note.isPinned).length > 0 && (
|
||||
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
||||
<NotesMainSection
|
||||
viewMode={notesViewMode}
|
||||
notes={notes.filter((note) => !note.isPinned)}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
currentNotebookId={searchParams.get('notebook')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{t('notes.emptyState')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MemoryEchoNotification onOpenNote={handleOpenNote} />
|
||||
|
||||
{notebookSuggestion && (
|
||||
<NotebookSuggestionToast
|
||||
noteId={notebookSuggestion.noteId}
|
||||
noteContent={notebookSuggestion.content}
|
||||
onDismiss={() => setNotebookSuggestion(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{batchOrganizationOpen && (
|
||||
<BatchOrganizationDialog
|
||||
open={batchOrganizationOpen}
|
||||
onOpenChange={setBatchOrganizationOpen}
|
||||
onNotesMoved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{autoLabelOpen && (
|
||||
<AutoLabelSuggestionDialog
|
||||
open={autoLabelOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAutoLabelOpen(open)
|
||||
if (!open) dismissLabelSuggestion()
|
||||
}}
|
||||
notebookId={suggestNotebookId}
|
||||
onLabelsCreated={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
132
keep-notes/.backup-keep/masonry-grid.css
Normal file
132
keep-notes/.backup-keep/masonry-grid.css
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Masonry Grid — CSS Grid avec tailles visibles
|
||||
* Layout avec espaces minimisés via dense, drag-and-drop via @dnd-kit
|
||||
*/
|
||||
|
||||
/* ─── Container ──────────────────────────────────── */
|
||||
.masonry-container {
|
||||
width: 100%;
|
||||
padding: 0 8px 40px 8px;
|
||||
}
|
||||
|
||||
/* ─── CSS Grid avec dense pour minimiser les trous ─ */
|
||||
.masonry-css-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
grid-auto-rows: auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
grid-auto-flow: dense;
|
||||
}
|
||||
|
||||
/* ─── Sortable items ─────────────────────────────── */
|
||||
.masonry-sortable-item {
|
||||
break-inside: avoid;
|
||||
box-sizing: border-box;
|
||||
will-change: transform;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Taille des notes : small=1 colonne, medium=2 colonnes, large=3 colonnes */
|
||||
.masonry-sortable-item[data-size="medium"] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="large"] {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
/* ─── Note card base ─────────────────────────────── */
|
||||
.note-card {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ─── Drag overlay ───────────────────────────────── */
|
||||
.masonry-drag-overlay {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 12px;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Mobile (< 480px) : 1 colonne ──────────────── */
|
||||
@media (max-width: 479px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Sur mobile tout est 1 colonne */
|
||||
.masonry-sortable-item[data-size="medium"],
|
||||
.masonry-sortable-item[data-size="large"] {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.masonry-container {
|
||||
padding: 0 4px 16px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Small tablet (480–767px) : 2 colonnes ─────── */
|
||||
@media (min-width: 480px) and (max-width: 767px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Sur 2 colonnes, large prend tout */
|
||||
.masonry-sortable-item[data-size="large"] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.masonry-container {
|
||||
padding: 0 8px 20px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Tablet (768–1023px) ────────────────────────── */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Desktop (1024–1279px) ─────────────────────── */
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Large Desktop (1280px+) ───────────────────── */
|
||||
@media (min-width: 1280px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.masonry-container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 12px 32px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Print ──────────────────────────────────────── */
|
||||
@media print {
|
||||
.masonry-sortable-item {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Reduced motion ─────────────────────────────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.masonry-sortable-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
292
keep-notes/.backup-keep/masonry-grid.tsx
Normal file
292
keep-notes/.backup-keep/masonry-grid.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
rectSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Note } from '@/lib/types';
|
||||
import { NoteCard } from './note-card';
|
||||
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import dynamic from 'next/dynamic';
|
||||
import './masonry-grid.css';
|
||||
|
||||
// Lazy-load NoteEditor — uniquement chargé au clic
|
||||
const NoteEditor = dynamic(
|
||||
() => import('./note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface MasonryGridProps {
|
||||
notes: Note[];
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Sortable Note Item
|
||||
// ─────────────────────────────────────────────
|
||||
interface SortableNoteProps {
|
||||
note: Note;
|
||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
||||
onDragStartNote?: (noteId: string) => void;
|
||||
onDragEndNote?: () => void;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
}
|
||||
|
||||
const SortableNoteItem = memo(function SortableNoteItem({
|
||||
note,
|
||||
onEdit,
|
||||
onSizeChange,
|
||||
onDragStartNote,
|
||||
onDragEndNote,
|
||||
isDragging,
|
||||
isOverlay,
|
||||
}: SortableNoteProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({ id: note.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isSortableDragging && !isOverlay ? 0.3 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="masonry-sortable-item"
|
||||
data-id={note.id}
|
||||
data-size={note.size}
|
||||
>
|
||||
<NoteCard
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onDragStart={onDragStartNote}
|
||||
onDragEnd={onDragEndNote}
|
||||
isDragging={isDragging}
|
||||
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Sortable Grid Section (pinned or others)
|
||||
// ─────────────────────────────────────────────
|
||||
interface SortableGridSectionProps {
|
||||
notes: Note[];
|
||||
onEdit: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
|
||||
draggedNoteId: string | null;
|
||||
onDragStartNote: (noteId: string) => void;
|
||||
onDragEndNote: () => void;
|
||||
}
|
||||
|
||||
const SortableGridSection = memo(function SortableGridSection({
|
||||
notes,
|
||||
onEdit,
|
||||
onSizeChange,
|
||||
draggedNoteId,
|
||||
onDragStartNote,
|
||||
onDragEndNote,
|
||||
}: SortableGridSectionProps) {
|
||||
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
||||
|
||||
return (
|
||||
<SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
<div className="masonry-css-grid">
|
||||
{notes.map(note => (
|
||||
<SortableNoteItem
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onSizeChange={onSizeChange}
|
||||
onDragStartNote={onDragStartNote}
|
||||
onDragEndNote={onDragEndNote}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Main MasonryGrid component
|
||||
// ─────────────────────────────────────────────
|
||||
export function MasonryGrid({ notes, onEdit, onSizeChange }: MasonryGridProps) {
|
||||
const { t } = useLanguage();
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
||||
|
||||
// Local notes state for optimistic size/order updates
|
||||
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
}, [notes]);
|
||||
|
||||
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
|
||||
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activeNote = useMemo(
|
||||
() => localNotes.find(n => n.id === activeId) ?? null,
|
||||
[localNotes, activeId]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
|
||||
if (onEdit) {
|
||||
onEdit(note, readOnly);
|
||||
} else {
|
||||
setEditingNote({ note, readOnly });
|
||||
}
|
||||
}, [onEdit]);
|
||||
|
||||
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
|
||||
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
|
||||
onSizeChange?.(noteId, newSize);
|
||||
}, [onSizeChange]);
|
||||
|
||||
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
|
||||
})
|
||||
);
|
||||
|
||||
const localNotesRef = useRef<Note[]>(localNotes)
|
||||
useEffect(() => {
|
||||
localNotesRef.current = localNotes
|
||||
}, [localNotes])
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
startDrag(event.active.id as string);
|
||||
}, [startDrag]);
|
||||
|
||||
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
endDrag();
|
||||
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const reordered = arrayMove(
|
||||
localNotesRef.current,
|
||||
localNotesRef.current.findIndex(n => n.id === active.id),
|
||||
localNotesRef.current.findIndex(n => n.id === over.id),
|
||||
);
|
||||
|
||||
if (reordered.length === 0) return;
|
||||
|
||||
setLocalNotes(reordered);
|
||||
// Persist order outside of setState to avoid "setState in render" warning
|
||||
const ids = reordered.map(n => n.id);
|
||||
updateFullOrderWithoutRevalidation(ids).catch(err => {
|
||||
console.error('Failed to persist order:', err);
|
||||
});
|
||||
}, [endDrag]);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="masonry-dnd"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="masonry-container">
|
||||
{pinnedNotes.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
||||
{t('notes.pinned')}
|
||||
</h2>
|
||||
<SortableGridSection
|
||||
notes={pinnedNotes}
|
||||
onEdit={handleEdit}
|
||||
onSizeChange={handleSizeChange}
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{othersNotes.length > 0 && (
|
||||
<div>
|
||||
{pinnedNotes.length > 0 && (
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
||||
{t('notes.others')}
|
||||
</h2>
|
||||
)}
|
||||
<SortableGridSection
|
||||
notes={othersNotes}
|
||||
onEdit={handleEdit}
|
||||
onSizeChange={handleSizeChange}
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DragOverlay — montre une copie flottante pendant le drag */}
|
||||
<DragOverlay>
|
||||
{activeNote ? (
|
||||
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
|
||||
<NoteCard
|
||||
note={activeNote}
|
||||
onEdit={handleEdit}
|
||||
isDragging={true}
|
||||
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
||||
{editingNote && (
|
||||
<NoteEditor
|
||||
note={editingNote.note}
|
||||
readOnly={editingNote.readOnly}
|
||||
onClose={() => setEditingNote(null)}
|
||||
/>
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
677
keep-notes/.backup-keep/note-card.tsx
Normal file
677
keep-notes/.backup-keep/note-card.tsx
Normal file
@@ -0,0 +1,677 @@
|
||||
'use client'
|
||||
|
||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { es } from 'date-fns/locale/es'
|
||||
import { de } from 'date-fns/locale/de'
|
||||
import { faIR } from 'date-fns/locale/fa-IR'
|
||||
import { it } from 'date-fns/locale/it'
|
||||
import { pt } from 'date-fns/locale/pt'
|
||||
import { ru } from 'date-fns/locale/ru'
|
||||
import { zhCN } from 'date-fns/locale/zh-CN'
|
||||
import { ja } from 'date-fns/locale/ja'
|
||||
import { ko } from 'date-fns/locale/ko'
|
||||
import { ar } from 'date-fns/locale/ar'
|
||||
import { hi } from 'date-fns/locale/hi'
|
||||
import { nl } from 'date-fns/locale/nl'
|
||||
import { pl } from 'date-fns/locale/pl'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
import { CollaboratorDialog } from './collaborator-dialog'
|
||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
||||
import { ConnectionsBadge } from './connections-badge'
|
||||
import { ConnectionsOverlay } from './connections-overlay'
|
||||
import { ComparisonModal } from './comparison-modal'
|
||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// Mapping of supported languages to date-fns locales
|
||||
const localeMap: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
fr: fr,
|
||||
es: es,
|
||||
de: de,
|
||||
fa: faIR,
|
||||
it: it,
|
||||
pt: pt,
|
||||
ru: ru,
|
||||
zh: zhCN,
|
||||
ja: ja,
|
||||
ko: ko,
|
||||
ar: ar,
|
||||
hi: hi,
|
||||
nl: nl,
|
||||
pl: pl,
|
||||
}
|
||||
|
||||
function getDateLocale(language: string): Locale {
|
||||
return localeMap[language] || enUS
|
||||
}
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
function getNotebookIcon(iconName: string): LucideIcon {
|
||||
const IconComponent = ICON_MAP[iconName] || Folder
|
||||
return IconComponent
|
||||
}
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
isDragging?: boolean
|
||||
isDragOver?: boolean
|
||||
onDragStart?: (noteId: string) => void
|
||||
onDragEnd?: () => void
|
||||
onResize?: () => void
|
||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
||||
}
|
||||
|
||||
// Helper function to get initials from name
|
||||
function getInitials(name: string): string {
|
||||
if (!name) return '??'
|
||||
const trimmedName = name.trim()
|
||||
const parts = trimmedName.split(' ')
|
||||
if (parts.length === 1) {
|
||||
return trimmedName.substring(0, 2).toUpperCase()
|
||||
}
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
// Helper function to get avatar color based on name hash
|
||||
function getAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
'bg-primary',
|
||||
'bg-purple-500',
|
||||
'bg-green-500',
|
||||
'bg-orange-500',
|
||||
'bg-pink-500',
|
||||
'bg-teal-500',
|
||||
'bg-red-500',
|
||||
'bg-indigo-500',
|
||||
]
|
||||
|
||||
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return colors[hash % colors.length]
|
||||
}
|
||||
|
||||
export const NoteCard = memo(function NoteCard({
|
||||
note,
|
||||
onEdit,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
isDragging,
|
||||
onResize,
|
||||
onSizeChange
|
||||
}: NoteCardProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refreshLabels } = useLabels()
|
||||
const { data: session } = useSession()
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const [, startTransition] = useTransition()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
||||
const [owner, setOwner] = useState<any>(null)
|
||||
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
||||
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||
|
||||
// Move note to a notebook
|
||||
const handleMoveToNotebook = async (notebookId: string | null) => {
|
||||
await moveNoteToNotebookOptimistic(note.id, notebookId)
|
||||
setShowNotebookMenu(false)
|
||||
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
|
||||
}
|
||||
|
||||
// Optimistic UI state for instant feedback
|
||||
const [optimisticNote, addOptimisticNote] = useOptimistic(
|
||||
note,
|
||||
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
|
||||
)
|
||||
|
||||
// Local color state so color persists after transition ends
|
||||
const [localColor, setLocalColor] = useState(note.color)
|
||||
|
||||
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
// Check if this note is currently open in the editor
|
||||
const isNoteOpenInEditor = searchParams.get('note') === note.id
|
||||
|
||||
// Only fetch comparison notes when we have IDs to compare
|
||||
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
|
||||
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
|
||||
)
|
||||
|
||||
const currentUserId = session?.user?.id
|
||||
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
|
||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
||||
|
||||
// Load collaborators only for shared notes (not owned by current user)
|
||||
useEffect(() => {
|
||||
// Skip API call for notes owned by current user — no need to fetch collaborators
|
||||
if (!isSharedNote) {
|
||||
// For own notes, set owner to current user
|
||||
if (currentUserId && session?.user) {
|
||||
setOwner({
|
||||
id: currentUserId,
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
image: session.user.image,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
if (note.userId && isMounted) {
|
||||
try {
|
||||
const users = await getNoteAllUsers(note.id)
|
||||
if (isMounted) {
|
||||
setCollaborators(users)
|
||||
if (users.length > 0) {
|
||||
setOwner(users[0])
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load collaborators:', error)
|
||||
if (isMounted) {
|
||||
setCollaborators([])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadCollaborators()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
await refreshLabels()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isPinned: !note.isPinned })
|
||||
await togglePin(note.id, !note.isPinned)
|
||||
|
||||
if (!note.isPinned) {
|
||||
toast.success(t('notes.pinned') || 'Note pinned')
|
||||
} else {
|
||||
toast.info(t('notes.unpinned') || 'Note unpinned')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleArchive = async () => {
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ isArchived: !note.isArchived })
|
||||
await toggleArchive(note.id, !note.isArchived)
|
||||
})
|
||||
}
|
||||
|
||||
const handleColorChange = async (color: string) => {
|
||||
setLocalColor(color) // instant visual update, survives transition
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ color })
|
||||
await updateNote(note.id, { color }, { skipRevalidation: false })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
|
||||
// Notifier le parent immédiatement (hors transition) — c'est lui
|
||||
// qui détient la source de vérité via localNotes
|
||||
onSizeChange?.(size)
|
||||
onResize?.()
|
||||
|
||||
// Persister en arrière-plan
|
||||
updateSize(note.id, size).catch(err =>
|
||||
console.error('Failed to update note size:', err)
|
||||
)
|
||||
}
|
||||
|
||||
const handleCheckItem = async (checkItemId: string) => {
|
||||
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
|
||||
const updatedItems = note.checkItems.map(item =>
|
||||
item.id === checkItemId ? { ...item, checked: !item.checked } : item
|
||||
)
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ checkItems: updatedItems })
|
||||
await updateNote(note.id, { checkItems: updatedItems })
|
||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
if (confirm(t('notes.confirmLeaveShare'))) {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
setIsDeleting(true) // Hide the note from view
|
||||
} catch (error) {
|
||||
console.error('Failed to leave share:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent opening the note editor
|
||||
startTransition(async () => {
|
||||
addOptimisticNote({ autoGenerated: null })
|
||||
await removeFusedBadge(note.id)
|
||||
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
|
||||
})
|
||||
}
|
||||
|
||||
if (isDeleting) return null
|
||||
|
||||
const getMinHeight = (size?: string) => {
|
||||
switch (size) {
|
||||
case 'medium': return '350px'
|
||||
case 'large': return '500px'
|
||||
default: return '150px' // small
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-testid="note-card"
|
||||
data-draggable="true"
|
||||
data-note-id={note.id}
|
||||
data-size={optimisticNote.size}
|
||||
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
||||
draggable={true}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', note.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
||||
onDragStart?.(note.id)
|
||||
}}
|
||||
onDragEnd={() => onDragEnd?.()}
|
||||
className={cn(
|
||||
'note-card group relative rounded-2xl overflow-hidden p-5 border shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
'hover:shadow-xl hover:-translate-y-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.card,
|
||||
colorClasses.hover,
|
||||
colorClasses.hover,
|
||||
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
|
||||
)}
|
||||
onClick={(e) => {
|
||||
// Only trigger edit if not clicking on buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
|
||||
// For shared notes, pass readOnly flag
|
||||
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
||||
<div
|
||||
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
||||
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
title={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Move to Notebook Dropdown Menu */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
|
||||
title={t('notebookSuggestion.moveToNotebook')}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
{t('notebookSuggestion.moveToNotebook')}
|
||||
</div>
|
||||
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
|
||||
<StickyNote className="h-4 w-4 mr-2" />
|
||||
{t('notebookSuggestion.generalNotes')}
|
||||
</DropdownMenuItem>
|
||||
{notebooks.map((notebook: any) => {
|
||||
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={notebook.id}
|
||||
onClick={() => handleMoveToNotebook(notebook.id)}
|
||||
>
|
||||
<NotebookIcon className="h-4 w-4 mr-2" />
|
||||
{notebook.name}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Pin Button - Visible on hover or if pinned */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="pin-button"
|
||||
className={cn(
|
||||
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
|
||||
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTogglePin()
|
||||
}}
|
||||
>
|
||||
<Pin
|
||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
|
||||
|
||||
{/* Reminder Icon - Move slightly if pin button is there */}
|
||||
{note.reminder && new Date(note.reminder) > new Date() && (
|
||||
<Bell
|
||||
className="absolute top-3 right-10 h-4 w-4 text-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory Echo Badges - Fusion + Connections (BEFORE Title) */}
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{/* Fusion Badge with remove button */}
|
||||
{note.autoGenerated && (
|
||||
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative">
|
||||
<Link2 className="h-2.5 w-2.5" />
|
||||
{t('memoryEcho.fused')}
|
||||
<button
|
||||
onClick={handleRemoveFusedBadge}
|
||||
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
|
||||
title={t('notes.remove') || 'Remove'}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Badge */}
|
||||
<ConnectionsBadge
|
||||
noteId={note.id}
|
||||
onClick={() => {
|
||||
// Only open overlay if note is NOT open in editor
|
||||
// (to avoid having 2 Dialogs with 2 close buttons)
|
||||
if (!isNoteOpenInEditor) {
|
||||
setShowConnectionsOverlay(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{optimisticNote.title && (
|
||||
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
|
||||
{optimisticNote.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Search Match Type Badge */}
|
||||
{optimisticNote.matchType && (
|
||||
<Badge
|
||||
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
'mb-2 text-xs',
|
||||
optimisticNote.matchType === 'exact'
|
||||
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
|
||||
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
|
||||
)}
|
||||
>
|
||||
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Shared badge */}
|
||||
{isSharedNote && owner && (
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
|
||||
{t('notes.sharedBy')} {owner.name || owner.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleLeaveShare()
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-3 w-3 mr-1" />
|
||||
{t('notes.leaveShare')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Images Component */}
|
||||
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
|
||||
|
||||
{/* Link Previews */}
|
||||
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-2">
|
||||
{optimisticNote.links.map((link, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{link.imageUrl && (
|
||||
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
|
||||
)}
|
||||
<div className="p-2">
|
||||
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
|
||||
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
|
||||
<span className="text-[10px] text-primary mt-1 block">
|
||||
{new URL(link.url).hostname}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{optimisticNote.type === 'text' ? (
|
||||
<div className="text-sm text-foreground line-clamp-10">
|
||||
<MarkdownContent content={optimisticNote.content} />
|
||||
</div>
|
||||
) : (
|
||||
<NoteChecklist
|
||||
items={optimisticNote.checkItems || []}
|
||||
onToggleItem={handleCheckItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Labels - using shared LabelBadge component */}
|
||||
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{optimisticNote.labels.map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with Date only */}
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
{/* Creation Date */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner Avatar - Aligned with action buttons at bottom */}
|
||||
{owner && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-2 left-2 z-20",
|
||||
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
|
||||
getAvatarColor(owner.name || owner.email || 'Unknown')
|
||||
)}
|
||||
title={owner.name || owner.email || 'Unknown'}
|
||||
>
|
||||
{getInitials(owner.name || owner.email || '??')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar Component - Always show for now to fix regression */}
|
||||
{true && (
|
||||
<NoteActions
|
||||
isPinned={optimisticNote.isPinned}
|
||||
isArchived={optimisticNote.isArchived}
|
||||
currentColor={optimisticNote.color}
|
||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onColorChange={handleColorChange}
|
||||
onSizeChange={handleSizeChange}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Collaborator Dialog */}
|
||||
{currentUserId && note.userId && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<CollaboratorDialog
|
||||
open={showCollaboratorDialog}
|
||||
onOpenChange={setShowCollaboratorDialog}
|
||||
noteId={note.id}
|
||||
noteOwnerId={note.userId}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connections Overlay */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ConnectionsOverlay
|
||||
isOpen={showConnectionsOverlay}
|
||||
onClose={() => setShowConnectionsOverlay(false)}
|
||||
noteId={note.id}
|
||||
onOpenNote={(noteId) => {
|
||||
// Find the note and open it
|
||||
onEdit?.(note, false)
|
||||
}}
|
||||
onCompareNotes={(noteIds) => {
|
||||
setComparisonNotes(noteIds)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comparison Modal */}
|
||||
{comparisonNotes && comparisonNotesData.length > 0 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ComparisonModal
|
||||
isOpen={!!comparisonNotes}
|
||||
onClose={() => setComparisonNotes(null)}
|
||||
notes={comparisonNotesData}
|
||||
onOpenNote={(noteId) => {
|
||||
const foundNote = comparisonNotesData.find(n => n.id === noteId)
|
||||
if (foundNote) {
|
||||
onEdit?.(foundNote, false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
||||
{t('notes.delete') || 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
44
keep-notes/.backup-keep/notes-main-section.tsx
Normal file
44
keep-notes/.backup-keep/notes-main-section.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Note } from '@/lib/types'
|
||||
import { NotesTabsView } from '@/components/notes-tabs-view'
|
||||
|
||||
const MasonryGridLazy = dynamic(
|
||||
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="min-h-[200px] rounded-xl border border-dashed border-muted-foreground/20 bg-muted/30 animate-pulse"
|
||||
aria-hidden
|
||||
/>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export type NotesViewMode = 'masonry' | 'tabs'
|
||||
|
||||
interface NotesMainSectionProps {
|
||||
notes: Note[]
|
||||
viewMode: NotesViewMode
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
||||
currentNotebookId?: string | null
|
||||
}
|
||||
|
||||
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
|
||||
if (viewMode === 'tabs') {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
||||
<NotesTabsView notes={notes} onEdit={onEdit} currentNotebookId={currentNotebookId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="notes-grid">
|
||||
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
899
keep-notes/PLAN_NOVEL_EDITOR.md
Normal file
899
keep-notes/PLAN_NOVEL_EDITOR.md
Normal file
@@ -0,0 +1,899 @@
|
||||
# Plan Technique - Intégration Novel Editor
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Intégration d'un éditeur riche type Notion basé sur **Novel.sh** (Tiptap) pour remplacer le textarea actuel en mode desktop, tout en conservant un textarea amélioré pour le mobile.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Générale
|
||||
|
||||
### 1.1 Structure des fichiers
|
||||
|
||||
```
|
||||
keep-notes/
|
||||
├── app/
|
||||
│ └── api/
|
||||
│ └── ai/
|
||||
│ └── editor/ # Endpoints AI pour l'éditeur
|
||||
│ ├── improve/route.ts
|
||||
│ └── shorten/route.ts
|
||||
├── components/
|
||||
│ ├── editor/
|
||||
│ │ ├── novel-editor.tsx # Composant Novel principal
|
||||
│ │ ├── mobile-textarea.tsx # Fallback textarea mobile
|
||||
│ │ ├── editor-container.tsx # Switch desktop/mobile
|
||||
│ │ ├── slash-commands.tsx # Commandes slash (/heading, /list)
|
||||
│ │ ├── ai-commands.tsx # Commandes AI (/ai improve)
|
||||
│ │ ├── editor-toolbar.tsx # Toolbar sticky contextuelle
|
||||
│ │ └── markdown-preview.tsx # Preview mode carte
|
||||
│ ├── note-inline-editor.tsx # MODIFIÉ - Intègre EditorContainer
|
||||
│ └── note-card.tsx # Inchangé - Affiche aperçu texte
|
||||
├── lib/
|
||||
│ ├── editor/
|
||||
│ │ ├── novel-config.ts # Configuration Tiptap/Novel
|
||||
│ │ ├── markdown-converter.ts # MD ↔ JSON conversion
|
||||
│ │ ├── editor-utils.ts # Helpers (extract text, etc.)
|
||||
│ │ └── extensions/ # Extensions custom Tiptap
|
||||
│ │ ├── checklist-extension.ts
|
||||
│ │ └── ai-extension.ts
|
||||
│ └── ai/
|
||||
│ └── editor-commands.ts # Intégration AI dans l'éditeur
|
||||
├── hooks/
|
||||
│ ├── use-novel-editor.ts # Hook gestion état Novel
|
||||
│ ├── use-editor-save.ts # Hook sauvegarde auto
|
||||
│ └── use-device-type.ts # Détection desktop/mobile
|
||||
└── types/
|
||||
└── editor.types.ts # Types TypeScript pour l'éditeur
|
||||
```
|
||||
|
||||
### 1.2 Flux de données
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FLUX DE DONNÉES │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
[Mode Liste - Édition]
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ EditorContainer │◄──── Détection mobile/desktop
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────┐ ┌──────────┐
|
||||
│ Novel │ │ Textarea │
|
||||
│Editor │ │ (mobile) │
|
||||
└───┬───┘ └────┬─────┘
|
||||
│ │
|
||||
└─────┬─────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Markdown (JSON) │◄──── Format de stockage
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Sauvegarde │◄──── API /api/notes/[id]
|
||||
│ Auto (1.5s) │
|
||||
└─────────────────┘
|
||||
|
||||
[Mode Carte - Affichage]
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ getNotePreview │◄──── Extrait texte brut du Markdown
|
||||
│ (existing) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Note Card │◄──── Affiche 2 lignes max
|
||||
│ (unchanged) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Dépendances
|
||||
|
||||
### 2.1 Core (obligatoires)
|
||||
|
||||
```bash
|
||||
# Novel et Tiptap
|
||||
npm install novel
|
||||
npm install @tiptap/core @tiptap/starter-kit
|
||||
|
||||
# Extensions essentielles
|
||||
npm install @tiptap/extension-task-list
|
||||
npm install @tiptap/extension-task-item
|
||||
npm install @tiptap/extension-placeholder
|
||||
npm install @tiptap/extension-link
|
||||
npm install @tiptap/extension-underline
|
||||
npm install @tiptap/extension-highlight
|
||||
npm install @tiptap/extension-code-block
|
||||
npm install @tiptap/extension-blockquote
|
||||
npm install @tiptap/extension-horizontal-rule
|
||||
|
||||
# Markdown
|
||||
npm install @tiptap/extension-markdown
|
||||
```
|
||||
|
||||
### 2.2 UI (shadcn/radix déjà présents)
|
||||
|
||||
```bash
|
||||
# Déjà inclus avec shadcn
|
||||
# - @radix-ui/react-popover
|
||||
# - @radix-ui/react-toolbar
|
||||
# - class-variance-authority
|
||||
# - clsx / tailwind-merge
|
||||
```
|
||||
|
||||
### 2.3 Types
|
||||
|
||||
```bash
|
||||
npm install -D @types/tiptap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Composants détaillés
|
||||
|
||||
### 3.1 EditorContainer (`components/editor/editor-container.tsx`)
|
||||
|
||||
**Responsabilité :** Point d'entrée unique, détecte le device et route vers le bon éditeur.
|
||||
|
||||
```typescript
|
||||
interface EditorContainerProps {
|
||||
content: string; // Markdown
|
||||
onChange: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
enableAI?: boolean; // Active les commandes AI
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function EditorContainer(props: EditorContainerProps) {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileTextarea {...props} />;
|
||||
}
|
||||
|
||||
return <NovelEditor {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 NovelEditor (`components/editor/novel-editor.tsx`)
|
||||
|
||||
**Responsabilité :** Éditeur riche avec toutes les fonctionnalités.
|
||||
|
||||
**Props :**
|
||||
```typescript
|
||||
interface NovelEditorProps {
|
||||
value: string; // Markdown initial
|
||||
onChange: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
enableAI?: boolean;
|
||||
extensions?: Extension[]; // Extensions Tiptap additionnelles
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration :**
|
||||
```typescript
|
||||
const defaultExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
bulletList: {},
|
||||
orderedList: {},
|
||||
codeBlock: {},
|
||||
blockquote: {},
|
||||
horizontalRule: {},
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
HTMLAttributes: { class: 'flex items-start gap-2' }
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: { class: 'text-primary underline' }
|
||||
}),
|
||||
Underline,
|
||||
Highlight.configure({ multicolor: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Écris / pour voir les commandes...'
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: false,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
### 3.3 MobileTextarea (`components/editor/mobile-textarea.tsx`)
|
||||
|
||||
**Responsabilité :** Textarea amélioré pour mobile avec toolbar minimal.
|
||||
|
||||
**Features :**
|
||||
- Toolbar sticky bottom (B, I, List, Checklist)
|
||||
- Markdown shortcuts (## pour H2, - pour liste)
|
||||
- Auto-grow height
|
||||
- Swipe gestures (optionnel)
|
||||
|
||||
```typescript
|
||||
interface MobileTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 SlashCommands (`components/editor/slash-commands.tsx`)
|
||||
|
||||
**Responsabilité :** Menu commandes déclenché par `/`.
|
||||
|
||||
**Commandes implémentées :**
|
||||
|
||||
| Commande | Action | Icône |
|
||||
|----------|--------|-------|
|
||||
| `/h1` | Titre niveau 1 | Heading1 |
|
||||
| `/h2` | Titre niveau 2 | Heading2 |
|
||||
| `/h3` | Titre niveau 3 | Heading3 |
|
||||
| `/list` | Liste à puces | List |
|
||||
| `/num` | Liste numérotée | ListOrdered |
|
||||
| `/check` | Checklist | CheckSquare |
|
||||
| `/quote` | Citation | Quote |
|
||||
| `/code` | Bloc de code | Code |
|
||||
| `/line` | Ligne horizontale | Minus |
|
||||
| `/ai` | Commandes AI | Sparkles |
|
||||
|
||||
### 3.5 AICommands (`components/editor/ai-commands.tsx`)
|
||||
|
||||
**Responsabilité :** Intégration AI dans l'éditeur.
|
||||
|
||||
**Commandes AI :**
|
||||
|
||||
| Commande | Description | Raccourci |
|
||||
|----------|-------------|-----------|
|
||||
| `/ai improve` | Améliore la rédaction | Ctrl+Shift+I |
|
||||
| `/ai shorten` | Raccourcit le texte | Ctrl+Shift+S |
|
||||
| `/ai longer` | Développe le texte | Ctrl+Shift+L |
|
||||
| `/ai fix` | Corrige orthographe | - |
|
||||
| `/ai title` | Génère titre depuis contenu | - |
|
||||
|
||||
### 3.6 EditorToolbar (`components/editor/editor-toolbar.tsx`)
|
||||
|
||||
**Responsabilité :** Toolbar contextuelle (bubble menu) et sticky.
|
||||
|
||||
**Modes :**
|
||||
|
||||
1. **Bubble Menu** (texte sélectionné) :
|
||||
- Bold, Italic, Strike, Underline
|
||||
- Highlight
|
||||
- Link
|
||||
- AI Improve (si sélection > 10 mots)
|
||||
|
||||
2. **Floating Menu** (ligne vide) :
|
||||
- + pour ajouter bloc
|
||||
- Raccourcis rapides
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration Novel
|
||||
|
||||
### 4.1 novel-config.ts
|
||||
|
||||
```typescript
|
||||
// lib/editor/novel-config.ts
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
// ... autres imports
|
||||
|
||||
export const novelConfig = {
|
||||
// Extensions de base
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') return 'Titre...';
|
||||
return 'Écris / pour commencer...';
|
||||
}
|
||||
}),
|
||||
Markdown.configure({
|
||||
transformCopiedText: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
],
|
||||
|
||||
// Options d'éditeur
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm dark:prose-invert max-w-none focus:outline-none',
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (view, event) => {
|
||||
// Custom keyboard shortcuts
|
||||
if (event.key === 'Tab' && event.shiftKey) {
|
||||
// Outdent
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Thème
|
||||
theme: {
|
||||
color: 'inherit',
|
||||
backgroundColor: 'transparent',
|
||||
}
|
||||
};
|
||||
|
||||
// Commandes slash
|
||||
export const slashCommands = [
|
||||
{
|
||||
title: 'Titre 1',
|
||||
description: 'Grand titre',
|
||||
searchTerms: ['h1', 'title', 'titre'],
|
||||
icon: Heading1,
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleHeading({ level: 1 }).run();
|
||||
}
|
||||
},
|
||||
// ... autres commandes
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Conversion Markdown
|
||||
|
||||
### 5.1 markdown-converter.ts
|
||||
|
||||
**Responsabilité :** Convertir entre Markdown (stockage) et JSON Novel (édition).
|
||||
|
||||
```typescript
|
||||
// lib/editor/markdown-converter.ts
|
||||
|
||||
import { generateJSON } from '@tiptap/html';
|
||||
import { generateHTML } from '@tiptap/core';
|
||||
import { novelExtensions } from './novel-config';
|
||||
|
||||
/**
|
||||
* Convertit Markdown en JSON Novel
|
||||
* Utilisé au chargement d'une note
|
||||
*/
|
||||
export function markdownToNovelJSON(markdown: string): JSONContent {
|
||||
// Tiptap Markdown extension parse le MD
|
||||
const html = markdownToHtml(markdown); // Utilise marked ou similar
|
||||
return generateJSON(html, novelExtensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit JSON Novel en Markdown
|
||||
* Utilisé pour la sauvegarde
|
||||
*/
|
||||
export function novelJSONToMarkdown(json: JSONContent): string {
|
||||
const html = generateHTML(json, novelExtensions);
|
||||
return htmlToMarkdown(html); // Utilise turndown ou similar
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait le texte brut pour les cartes
|
||||
* Utilisé dans note-card.tsx
|
||||
*/
|
||||
export function extractTextFromNovelJSON(json: JSONContent): string {
|
||||
// Récursion sur les nodes pour extraire le texte
|
||||
let text = '';
|
||||
|
||||
function traverse(node: any) {
|
||||
if (node.text) {
|
||||
text += node.text + ' ';
|
||||
}
|
||||
if (node.content) {
|
||||
node.content.forEach(traverse);
|
||||
}
|
||||
}
|
||||
|
||||
traverse(json);
|
||||
return text.trim().slice(0, 200); // Limite pour les cartes
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Hooks
|
||||
|
||||
### 6.1 useNovelEditor (`hooks/use-novel-editor.ts`)
|
||||
|
||||
```typescript
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import { markdownToNovelJSON, novelJSONToMarkdown } from '@/lib/editor/markdown-converter';
|
||||
|
||||
export function useNovelEditor(initialMarkdown: string) {
|
||||
const [markdown, setMarkdown] = useState(initialMarkdown);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: novelExtensions,
|
||||
content: markdownToNovelJSON(initialMarkdown),
|
||||
onUpdate: ({ editor }) => {
|
||||
const json = editor.getJSON();
|
||||
const newMarkdown = novelJSONToMarkdown(json);
|
||||
setMarkdown(newMarkdown);
|
||||
},
|
||||
});
|
||||
|
||||
const updateContent = useCallback((newMarkdown: string) => {
|
||||
if (editor && newMarkdown !== markdown) {
|
||||
editor.commands.setContent(markdownToNovelJSON(newMarkdown));
|
||||
}
|
||||
}, [editor, markdown]);
|
||||
|
||||
return {
|
||||
editor,
|
||||
markdown,
|
||||
updateContent,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 useEditorSave (`hooks/use-editor-save.ts`)
|
||||
|
||||
```typescript
|
||||
import { useDebounce } from './use-debounce';
|
||||
|
||||
export function useEditorSave(noteId: string) {
|
||||
const [content, setContent] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const debouncedContent = useDebounce(content, 1500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedContent && isDirty) {
|
||||
saveInline(noteId, { content: debouncedContent });
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [debouncedContent, noteId, isDirty]);
|
||||
|
||||
const updateContent = (newContent: string) => {
|
||||
setContent(newContent);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
return {
|
||||
content,
|
||||
updateContent,
|
||||
isDirty,
|
||||
isSaving: isDirty,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 useDeviceType (`hooks/use-device-type.ts`)
|
||||
|
||||
```typescript
|
||||
import { useMediaQuery } from './use-media-query';
|
||||
|
||||
export function useDeviceType() {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)');
|
||||
const isDesktop = useMediaQuery('(min-width: 1025px)');
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isTouch: isMobile || isTablet,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Intégration avec NoteInlineEditor
|
||||
|
||||
### 7.1 Modification de note-inline-editor.tsx
|
||||
|
||||
```typescript
|
||||
// components/note-inline-editor.tsx
|
||||
|
||||
import { EditorContainer } from './editor/editor-container';
|
||||
|
||||
export function NoteInlineEditor({ note, onChange, onDelete, onArchive }: NoteInlineEditorProps) {
|
||||
const { t } = useLanguage();
|
||||
const { deviceType } = useDeviceType();
|
||||
|
||||
// ... autres states (title, isMarkdown, etc.)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Toolbar existante (image, link, markdown toggle, AI) */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b...">
|
||||
{/* ... existing toolbar buttons ... */}
|
||||
</div>
|
||||
|
||||
{/* Zone d'édition - NOUVEAU */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto px-8 py-5">
|
||||
{/* Titre */}
|
||||
<input
|
||||
type="text"
|
||||
className="..."
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
changeTitle(e.target.value);
|
||||
scheduleSave();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Contenu - REMPLACÉ PAR EditorContainer */}
|
||||
<div className="mt-4 flex flex-1 flex-col">
|
||||
{note.type === 'text' ? (
|
||||
<EditorContainer
|
||||
content={content}
|
||||
onChange={(newContent) => {
|
||||
changeContent(newContent);
|
||||
scheduleSave();
|
||||
}}
|
||||
placeholder={t('notes.takeNote')}
|
||||
enableAI={true}
|
||||
/>
|
||||
) : (
|
||||
/* Checklist existante - Gardée telle quelle ou migrée vers Tiptap */
|
||||
<ChecklistEditor ... />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Mode Carte (NoteCard)
|
||||
|
||||
### 8.1 Impact sur note-card.tsx
|
||||
|
||||
**Aucune modification majeure requise.**
|
||||
|
||||
Le système actuel utilise `getNotePreview()` qui extrait le texte du Markdown. Comme on continue de stocker du Markdown, la compatibilité est assurée.
|
||||
|
||||
```typescript
|
||||
// Pas de changement nécessaire dans note-card.tsx
|
||||
// getNotePreview() continue de fonctionner avec le Markdown
|
||||
|
||||
function getNotePreview(note: Note, maxLength = 150): string {
|
||||
const content = note.content || '';
|
||||
// Supprime la syntaxe Markdown pour l'affichage
|
||||
const plainText = content
|
||||
.replace(/#+ /g, '') // Titres
|
||||
.replace(/\*\*/g, '') // Gras
|
||||
.replace(/\*/g, '') // Italique
|
||||
.replace(/- \[([ x])\] /g, '') // Checklists
|
||||
.replace(/- /g, '') // Listes
|
||||
.replace(/\n/g, ' '); // Sauts de ligne
|
||||
|
||||
return plainText.slice(0, maxLength) + (plainText.length > maxLength ? '...' : '');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. API AI pour l'Éditeur
|
||||
|
||||
### 9.1 POST /api/ai/editor/improve
|
||||
|
||||
```typescript
|
||||
// app/api/ai/editor/improve/route.ts
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auth } from '@/auth';
|
||||
import { getAIProvider } from '@/lib/ai/factory';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { text, language } = await req.json();
|
||||
|
||||
if (!text || text.length < 10) {
|
||||
return NextResponse.json({ error: 'Text too short' }, { status: 400 });
|
||||
}
|
||||
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
|
||||
const prompt = `Améliore ce texte en ${language || 'français'} pour le rendre plus clair et fluide.
|
||||
Garde le même sens mais améliore la formulation.
|
||||
|
||||
Texte: ${text}
|
||||
|
||||
Réponds uniquement avec le texte amélioré, sans explications.`;
|
||||
|
||||
const improved = await provider.generateText(prompt);
|
||||
|
||||
return NextResponse.json({ text: improved.trim() });
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 POST /api/ai/editor/shorten
|
||||
|
||||
Similaire avec prompt pour raccourcir.
|
||||
|
||||
---
|
||||
|
||||
## 10. Responsive Strategy
|
||||
|
||||
### 10.1 Breakpoints
|
||||
|
||||
| Breakpoint | Device | Composant | Raison |
|
||||
|------------|--------|-----------|--------|
|
||||
| `< 640px` | Mobile | Textarea | Performance, touch friendly |
|
||||
| `640px - 768px` | Large Mobile | Textarea | Touche bientôt desktop |
|
||||
| `768px - 1024px` | Tablet | Novel (light) | Plus d'espace, mais UX tactile |
|
||||
| `> 1024px` | Desktop | Novel (full) | Toutes les fonctionnalités |
|
||||
|
||||
### 10.2 Détection côté serveur (optionnel)
|
||||
|
||||
```typescript
|
||||
// Pour éviter le flash au chargement
|
||||
import { headers } from 'next/headers';
|
||||
import { userAgent } from 'next/server';
|
||||
|
||||
export function detectMobile() {
|
||||
const { device } = userAgent({ headers: headers() });
|
||||
return device?.type === 'mobile';
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklists
|
||||
|
||||
### 11.1 Approche recommandée : Hybride
|
||||
|
||||
**Stockage :** Markdown natif `- [ ]` et `- [x]`
|
||||
|
||||
**Pourquoi ?**
|
||||
- ✅ Standard Markdown (portable)
|
||||
- ✅ Lisible en mode carte
|
||||
- ✅ Éditable partout
|
||||
- ✅ Compatible export/import
|
||||
|
||||
**Implémentation :**
|
||||
```markdown
|
||||
# Ma note
|
||||
|
||||
- [ ] Tâche à faire
|
||||
- [x] Tâche complétée
|
||||
- [ ] Sous-tâche
|
||||
- [ ] Item 1
|
||||
- [x] Item 2
|
||||
```
|
||||
|
||||
### 11.2 Extensions Tiptap pour checklists
|
||||
|
||||
```typescript
|
||||
import TaskList from '@tiptap/extension-task-list';
|
||||
import TaskItem from '@tiptap/extension-task-item';
|
||||
|
||||
TaskItem.configure({
|
||||
nested: true,
|
||||
HTMLAttributes: {
|
||||
class: 'flex items-start gap-2 my-1',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Tests
|
||||
|
||||
### 12.1 Tests unitaires
|
||||
|
||||
```typescript
|
||||
// __tests__/markdown-converter.test.ts
|
||||
|
||||
describe('Markdown Converter', () => {
|
||||
test('converts checklist markdown to JSON', () => {
|
||||
const md = '- [ ] Task 1\n- [x] Task 2';
|
||||
const json = markdownToNovelJSON(md);
|
||||
|
||||
expect(json.content[0].type).toBe('taskList');
|
||||
expect(json.content[0].content).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('converts headings correctly', () => {
|
||||
const md = '# Title\n## Subtitle';
|
||||
const json = markdownToNovelJSON(md);
|
||||
|
||||
expect(json.content[0].type).toBe('heading');
|
||||
expect(json.content[0].attrs.level).toBe(1);
|
||||
});
|
||||
|
||||
test('extracts plain text for cards', () => {
|
||||
const json = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'heading', content: [{ text: 'Title' }] },
|
||||
{ type: 'paragraph', content: [{ text: 'Content' }] },
|
||||
]
|
||||
};
|
||||
|
||||
const text = extractTextFromNovelJSON(json);
|
||||
expect(text).toBe('Title Content');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 12.2 Tests e2e
|
||||
|
||||
```typescript
|
||||
// tests/editor.spec.ts
|
||||
|
||||
test('user can create a checklist in novel editor', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.click('[data-testid="new-note"]');
|
||||
await page.click('[data-testid="list-view-toggle"]');
|
||||
|
||||
// Type slash command
|
||||
await page.click('[data-testid="note-editor"]');
|
||||
await page.keyboard.type('/check');
|
||||
await page.click('text=Checklist');
|
||||
|
||||
// Type checklist item
|
||||
await page.keyboard.type('My task');
|
||||
|
||||
// Verify checkbox appears
|
||||
await expect(page.locator('input[type="checkbox"]')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Timeline Détaillée
|
||||
|
||||
### Jour 1 : Setup et Configuration (4-5h)
|
||||
|
||||
**Tâches :**
|
||||
- [ ] Installer toutes les dépendances Novel/Tiptap
|
||||
- [ ] Créer `lib/editor/novel-config.ts`
|
||||
- [ ] Créer `components/editor/novel-editor.tsx` (basique)
|
||||
- [ ] Vérifier que Novel s'affiche correctement
|
||||
|
||||
**Livrable :** Novel affiche un texte simple, sauvegarde en Markdown.
|
||||
|
||||
### Jour 2 : Fonctionnalités Essentielles (6-7h)
|
||||
|
||||
**Tâches :**
|
||||
- [ ] Créer `slash-commands.tsx` avec toutes les commandes
|
||||
- [ ] Configurer extensions checklists
|
||||
- [ ] Créer `editor-toolbar.tsx` (bubble menu)
|
||||
- [ ] Intégrer Markdown converter
|
||||
- [ ] Tester import/export Markdown
|
||||
|
||||
**Livrable :** Éditeur fonctionnel avec slash commands et checklists.
|
||||
|
||||
### Jour 3 : Intégration Système (6-7h)
|
||||
|
||||
**Tâches :**
|
||||
- [ ] Modifier `note-inline-editor.tsx` pour utiliser EditorContainer
|
||||
- [ ] Créer `hooks/use-novel-editor.ts`
|
||||
- [ ] Créer `hooks/use-editor-save.ts`
|
||||
- [ ] Connecter sauvegarde auto existante
|
||||
- [ ] Vérifier compatibilité mode liste/carte
|
||||
|
||||
**Livrable :** Novel fonctionne dans l'app, sauvegarde auto OK.
|
||||
|
||||
### Jour 4 : Mobile et Responsive (5-6h)
|
||||
|
||||
**Tâches :**
|
||||
- [ ] Créer `mobile-textarea.tsx` avec toolbar
|
||||
- [ ] Créer `editor-container.tsx` avec switch desktop/mobile
|
||||
- [ ] Implémenter `use-device-type.ts`
|
||||
- [ ] Tester sur différentes tailles d'écran
|
||||
- [ ] Optimiser performance mobile
|
||||
|
||||
**Livrable :** Textarea sur mobile, Novel sur desktop.
|
||||
|
||||
### Jour 5 : AI et Polish (5-6h)
|
||||
|
||||
**Tâches :**
|
||||
- [ ] Créer `/api/ai/editor/improve` et `/shorten`
|
||||
- [ ] Créer `ai-commands.tsx`
|
||||
- [ ] Intégrer commandes AI dans Novel
|
||||
- [ ] Tests et correction de bugs
|
||||
- [ ] Documentation
|
||||
|
||||
**Livrable :** Commandes AI fonctionnelles, tests passent.
|
||||
|
||||
### Jour 6 : Tests et Déploiement (4-5h)
|
||||
|
||||
**Tâches :**
|
||||
- [ ] Tests cross-navigateurs
|
||||
- [ ] Test avec 50+ notes
|
||||
- [ ] Test utilisateurs (interne)
|
||||
- [ ] Corrections finales
|
||||
- [ ] Merge et déploiement
|
||||
|
||||
**Total : 6 jours (30-36h de développement)**
|
||||
|
||||
---
|
||||
|
||||
## 14. Risques et Mitigations
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
|--------|-------------|--------|------------|
|
||||
| Bundle size trop grand | Moyenne | Moyen | Tree-shaking, lazy load Novel |
|
||||
| Perf sur vieux devices | Moyenne | Haut | Fallback textarea automatique |
|
||||
| Migration données | Faible | Haut | Tests exhaustifs MD ↔ JSON |
|
||||
| UX change trop radicale | Faible | Moyen | Feature flag, rollback possible |
|
||||
| Conflits avec checklists existantes | Moyenne | Haut | Garder format MD compatible |
|
||||
|
||||
---
|
||||
|
||||
## 15. Checklist de Validation
|
||||
|
||||
Avant de merger :
|
||||
|
||||
- [ ] Novel fonctionne sur Chrome, Firefox, Safari
|
||||
- [ ] Mobile textarea fonctionne sur iOS/Android
|
||||
- [ ] Sauvegarde auto fonctionne (pas de régression)
|
||||
- [ ] Mode liste ↔ Mode carte sans perte de données
|
||||
- [ ] Checklists s'affichent correctement dans les deux modes
|
||||
- [ ] Export Markdown identique à l'entrée
|
||||
- [ ] Tests unitaires passent
|
||||
- [ ] Tests e2e passent
|
||||
- [ ] Bundle size < +150KB gzipped
|
||||
- [ ] Performance OK avec 50 notes
|
||||
- [ ] Accessibilité (keyboard navigation, ARIA)
|
||||
|
||||
---
|
||||
|
||||
## 16. Ressources
|
||||
|
||||
### Documentation
|
||||
- [Novel.sh](https://novel.sh/)
|
||||
- [Tiptap Docs](https://tiptap.dev/)
|
||||
- [ProseMirror (base de Tiptap)](https://prosemirror.net/)
|
||||
|
||||
### Exemples
|
||||
- [Novel sur GitHub](https://github.com/steven-tey/novel)
|
||||
- [Tiptap examples](https://tiptap.dev/examples)
|
||||
|
||||
### Articles
|
||||
- [Building a Notion-like editor](https://tiptap.dev/blog/building-a-notion-like-editor)
|
||||
|
||||
---
|
||||
|
||||
**Document créé le :** 2024
|
||||
**Version :** 1.0
|
||||
**Auteur :** Assistant Claude
|
||||
**Statut :** Prêt pour développement
|
||||
|
||||
---
|
||||
|
||||
## Questions/Réponses
|
||||
|
||||
**Q : Puis-je encore utiliser Markdown brut ?**
|
||||
R : Oui, le stockage reste en Markdown. Vous pouvez éditer le MD directement si besoin.
|
||||
|
||||
**Q : Et si Novel ne me plaît pas ?**
|
||||
R : Retour au textarea est instantané (même format MD). Pas de vendor lock-in.
|
||||
|
||||
**Q : Les checklists existantes seront-elles conservées ?**
|
||||
R : Oui, format MD identique. Migration transparente.
|
||||
|
||||
**Q : Performance sur mobile ?**
|
||||
R : Textarea dédié mobile, donc meilleure perf qu'actuellement sur mobile.
|
||||
91
keep-notes/ai-debug.json
Normal file
91
keep-notes/ai-debug.json
Normal file
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"time": "2026-04-17T21:46:47.487Z",
|
||||
"error": "Invalid Responses API request",
|
||||
"stack": "AI_APICallError: Invalid Responses API request\n at /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3149:24\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async postToApi (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3022:36)\n at async OpenAIResponsesLanguageModel.doGenerate (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_@ai-sdk_openai_dist_index_mjs_062e1hg._.js:4982:77)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4360:52)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async _retryWithExponentialBackoff (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2627:16)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4319:48)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async generateText (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4140:16)\n at async CustomOpenAIProvider.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/%5Broot-of-the-server%5D__1153%7Ew7._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fproviders%2Fcustom-openai.ts+%5Bapp-rsc%5D+%28ecmascript%29:100:30)\n at async ChatService.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/keep-notes_00jwb0o._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fservices%2Fchat.service.ts+%5Bapp-rsc%5D+%28ecmascript%29:88:28)\n at async sendChatMessage (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:4715:24)\n at async executeActionAndPrepareForRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:5248)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:1986\n at async handleAction (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:62:25378)\n at async renderToHTMLOrFlightImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:69:55630)\n at async doRender (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:782:28)\n at async AppPageRouteModule.handleResponse (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:71:63567)\n at async handleResponse (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1057:32)\n at async Module.handler (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1460:20)\n at async DevServer.renderToResponseWithComponentsImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1454:9)\n at async DevServer.renderPageComponent (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1506:24)\n at async DevServer.renderToResponseImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1556:32)\n at async DevServer.pipeImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1043:25)\n at async NextNodeServer.handleCatchallRenderRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/next-server.js:338:17)\n at async DevServer.handleRequestImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:934:17)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:394:20\n at async Span.traceAsyncFn (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/trace/trace.js:164:20)\n at async DevServer.handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:390:24)\n at async invokeRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:253:21)\n at async handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:452:24)\n at async requestHandlerImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:501:13)\n at async Server.requestListener (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/start-server.js:225:13)",
|
||||
"failedMessage": "comment configurer le MCP de Momento ?",
|
||||
"conversationId": "cmo3e21d1000d84i002durh2f",
|
||||
"data": {
|
||||
"error": {
|
||||
"message": "Invalid Responses API request",
|
||||
"code": "invalid_prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"time": "2026-04-17T21:47:46.793Z",
|
||||
"error": "Invalid Responses API request",
|
||||
"stack": "AI_APICallError: Invalid Responses API request\n at /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3149:24\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async postToApi (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3022:36)\n at async OpenAIResponsesLanguageModel.doGenerate (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_@ai-sdk_openai_dist_index_mjs_062e1hg._.js:4982:77)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4360:52)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async _retryWithExponentialBackoff (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2627:16)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4319:48)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async generateText (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4140:16)\n at async CustomOpenAIProvider.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/%5Broot-of-the-server%5D__1153%7Ew7._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fproviders%2Fcustom-openai.ts+%5Bapp-rsc%5D+%28ecmascript%29:100:30)\n at async ChatService.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/keep-notes_00jwb0o._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fservices%2Fchat.service.ts+%5Bapp-rsc%5D+%28ecmascript%29:88:28)\n at async sendChatMessage (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:4715:24)\n at async executeActionAndPrepareForRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:5248)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:1986\n at async handleAction (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:62:25378)\n at async renderToHTMLOrFlightImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:69:55630)\n at async doRender (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:782:28)\n at async AppPageRouteModule.handleResponse (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:71:63567)\n at async handleResponse (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1057:32)\n at async Module.handler (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1460:20)\n at async DevServer.renderToResponseWithComponentsImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1454:9)\n at async DevServer.renderPageComponent (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1506:24)\n at async DevServer.renderToResponseImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1556:32)\n at async DevServer.pipeImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1043:25)\n at async NextNodeServer.handleCatchallRenderRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/next-server.js:338:17)\n at async DevServer.handleRequestImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:934:17)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:394:20\n at async Span.traceAsyncFn (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/trace/trace.js:164:20)\n at async DevServer.handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:390:24)\n at async invokeRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:253:21)\n at async handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:452:24)\n at async requestHandlerImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:501:13)\n at async Server.requestListener (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/start-server.js:225:13)",
|
||||
"failedMessage": "donne moi la réponse que pour le MCP de Momento ",
|
||||
"conversationId": "cmo3fuycq001n84i00rdfpusm",
|
||||
"data": {
|
||||
"error": {
|
||||
"message": "Invalid Responses API request",
|
||||
"code": "invalid_prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"time": "2026-04-17T21:49:19.622Z",
|
||||
"error": "Invalid Responses API request",
|
||||
"stack": "AI_APICallError: Invalid Responses API request\n at /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3149:24\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async postToApi (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3022:36)\n at async OpenAIResponsesLanguageModel.doGenerate (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_@ai-sdk_openai_dist_index_mjs_062e1hg._.js:4982:77)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4360:52)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async _retryWithExponentialBackoff (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2627:16)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4319:48)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async generateText (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4140:16)\n at async CustomOpenAIProvider.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/%5Broot-of-the-server%5D__1153%7Ew7._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fproviders%2Fcustom-openai.ts+%5Bapp-rsc%5D+%28ecmascript%29:100:30)\n at async ChatService.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/keep-notes_00jwb0o._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fservices%2Fchat.service.ts+%5Bapp-rsc%5D+%28ecmascript%29:88:28)\n at async sendChatMessage (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:4715:24)\n at async executeActionAndPrepareForRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:5248)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:1986\n at async handleAction (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:62:25378)\n at async renderToHTMLOrFlightImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:69:55630)\n at async doRender (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:782:28)\n at async AppPageRouteModule.handleResponse (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:71:63567)\n at async handleResponse (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1057:32)\n at async Module.handler (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1460:20)\n at async DevServer.renderToResponseWithComponentsImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1454:9)\n at async DevServer.renderPageComponent (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1506:24)\n at async DevServer.renderToResponseImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1556:32)\n at async DevServer.pipeImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1043:25)\n at async NextNodeServer.handleCatchallRenderRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/next-server.js:338:17)\n at async DevServer.handleRequestImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:934:17)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:394:20\n at async Span.traceAsyncFn (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/trace/trace.js:164:20)\n at async DevServer.handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:390:24)\n at async invokeRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:253:21)\n at async handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:452:24)\n at async requestHandlerImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:501:13)\n at async Server.requestListener (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/start-server.js:225:13)",
|
||||
"failedMessage": "donne moi la réponse que pour le MCP de Momento\n\n",
|
||||
"conversationId": "cmo3fuycq001n84i00rdfpusm",
|
||||
"data": {
|
||||
"error": {
|
||||
"message": "Invalid Responses API request",
|
||||
"code": "invalid_prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"time": "2026-04-17T21:54:31.483Z",
|
||||
"error": "Invalid Responses API request",
|
||||
"stack": "AI_APICallError: Invalid Responses API request\n at /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3149:24\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async postToApi (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3022:36)\n at async OpenAIResponsesLanguageModel.doGenerate (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_@ai-sdk_openai_dist_index_mjs_062e1hg._.js:4982:77)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4360:52)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async _retryWithExponentialBackoff (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2627:16)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4319:48)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async generateText (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4140:16)\n at async CustomOpenAIProvider.chat (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:953:30)\n at async ChatService.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/keep-notes_00jwb0o._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fservices%2Fchat.service.ts+%5Bapp-rsc%5D+%28ecmascript%29:88:28)\n at async sendChatMessage (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:4725:24)\n at async executeActionAndPrepareForRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:5248)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:1986\n at async handleAction (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:62:25378)\n at async renderToHTMLOrFlightImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:69:55630)\n at async doRender (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:782:28)\n at async AppPageRouteModule.handleResponse (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:71:63567)\n at async handleResponse (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1057:32)\n at async Module.handler (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1460:20)\n at async DevServer.renderToResponseWithComponentsImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1454:9)\n at async DevServer.renderPageComponent (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1506:24)\n at async DevServer.renderToResponseImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1556:32)\n at async DevServer.pipeImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1043:25)\n at async NextNodeServer.handleCatchallRenderRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/next-server.js:338:17)\n at async DevServer.handleRequestImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:934:17)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:394:20\n at async Span.traceAsyncFn (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/trace/trace.js:164:20)\n at async DevServer.handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:390:24)\n at async invokeRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:253:21)\n at async handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:452:24)\n at async requestHandlerImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:501:13)\n at async Server.requestListener (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/start-server.js:225:13)",
|
||||
"failedMessage": "donne moi la réponse que pour le MCP de Momento\n\n",
|
||||
"conversationId": "cmo3fuycq001n84i00rdfpusm",
|
||||
"data": {
|
||||
"error": {
|
||||
"message": "Invalid Responses API request",
|
||||
"code": "invalid_prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"time": "2026-04-17T21:59:02.833Z",
|
||||
"error": "Invalid Responses API request",
|
||||
"stack": "AI_APICallError: Invalid Responses API request\n at /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3149:24\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async postToApi (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3022:36)\n at async OpenAIResponsesLanguageModel.doGenerate (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_@ai-sdk_openai_dist_index_mjs_062e1hg._.js:4982:77)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4360:52)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async _retryWithExponentialBackoff (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2627:16)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4319:48)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async generateText (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4140:16)\n at async CustomOpenAIProvider.chat (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:953:30)\n at async ChatService.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/keep-notes_00jwb0o._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fservices%2Fchat.service.ts+%5Bapp-rsc%5D+%28ecmascript%29:88:28)\n at async sendChatMessage (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:4725:24)\n at async executeActionAndPrepareForRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:5248)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:1986\n at async handleAction (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:62:25378)\n at async renderToHTMLOrFlightImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:69:55630)\n at async doRender (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:782:28)\n at async AppPageRouteModule.handleResponse (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:71:63567)\n at async handleResponse (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1057:32)\n at async Module.handler (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1460:20)\n at async DevServer.renderToResponseWithComponentsImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1454:9)\n at async DevServer.renderPageComponent (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1506:24)\n at async DevServer.renderToResponseImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1556:32)\n at async DevServer.pipeImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1043:25)\n at async NextNodeServer.handleCatchallRenderRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/next-server.js:338:17)\n at async DevServer.handleRequestImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:934:17)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:394:20\n at async Span.traceAsyncFn (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/trace/trace.js:164:20)\n at async DevServer.handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:390:24)\n at async invokeRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:253:21)\n at async handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:452:24)\n at async requestHandlerImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:501:13)\n at async Server.requestListener (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/start-server.js:225:13)",
|
||||
"failedMessage": "DONNE MOI LA LISTE DES DOCUEMENTS",
|
||||
"conversationId": "cmo3fz2y1001r84i0so32syoz",
|
||||
"data": {
|
||||
"error": {
|
||||
"message": "Invalid Responses API request",
|
||||
"code": "invalid_prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"time": "2026-04-17T22:00:04.341Z",
|
||||
"error": "Invalid Responses API request",
|
||||
"stack": "AI_APICallError: Invalid Responses API request\n at /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3149:24\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async postToApi (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3022:36)\n at async OpenAIResponsesLanguageModel.doGenerate (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_@ai-sdk_openai_dist_index_mjs_062e1hg._.js:4982:77)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4360:52)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async _retryWithExponentialBackoff (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2627:16)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4319:48)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async generateText (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4140:16)\n at async CustomOpenAIProvider.chat (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:953:30)\n at async ChatService.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/keep-notes_00jwb0o._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fservices%2Fchat.service.ts+%5Bapp-rsc%5D+%28ecmascript%29:88:28)\n at async sendChatMessage (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:4725:24)\n at async executeActionAndPrepareForRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:5248)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:1986\n at async handleAction (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:62:25378)\n at async renderToHTMLOrFlightImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:69:55630)\n at async doRender (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:782:28)\n at async AppPageRouteModule.handleResponse (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:71:63567)\n at async handleResponse (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1057:32)\n at async Module.handler (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1460:20)\n at async DevServer.renderToResponseWithComponentsImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1454:9)\n at async DevServer.renderPageComponent (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1506:24)\n at async DevServer.renderToResponseImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1556:32)\n at async DevServer.pipeImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1043:25)\n at async NextNodeServer.handleCatchallRenderRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/next-server.js:338:17)\n at async DevServer.handleRequestImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:934:17)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:394:20\n at async Span.traceAsyncFn (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/trace/trace.js:164:20)\n at async DevServer.handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:390:24)\n at async invokeRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:253:21)\n at async handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:452:24)\n at async requestHandlerImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:501:13)\n at async Server.requestListener (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/start-server.js:225:13)",
|
||||
"failedMessage": "non je parle de MCP de Momento ",
|
||||
"conversationId": "cmo3gb6xu001x84i0vg5qrw0q",
|
||||
"data": {
|
||||
"error": {
|
||||
"message": "Invalid Responses API request",
|
||||
"code": "invalid_prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
"time": "2026-04-17T22:01:53.263Z",
|
||||
"error": "Invalid Responses API request",
|
||||
"stack": "AI_APICallError: Invalid Responses API request\n at /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3149:24\n at process.processTicksAndRejections (node:internal/process/task_queues:104:5)\n at async postToApi (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__0y.fo96._.js:3022:36)\n at async OpenAIResponsesLanguageModel.doGenerate (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_@ai-sdk_openai_dist_index_mjs_062e1hg._.js:4982:77)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4360:52)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async _retryWithExponentialBackoff (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2627:16)\n at async fn (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4319:48)\n at async /Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:2369:28\n at async generateText (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_ai_dist_index_mjs_0co~f9y._.js:4140:16)\n at async CustomOpenAIProvider.chat (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:953:30)\n at async ChatService.chat (file:///Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/keep-notes_00jwb0o._.js?id=%5Bproject%5D%2Fkeep-notes%2Flib%2Fai%2Fservices%2Fchat.service.ts+%5Bapp-rsc%5D+%28ecmascript%29:88:28)\n at async sendChatMessage (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/[root-of-the-server]__04by0_w._.js:4725:24)\n at async executeActionAndPrepareForRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:5248)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:64:1986\n at async handleAction (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:62:25378)\n at async renderToHTMLOrFlightImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:69:55630)\n at async doRender (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:782:28)\n at async AppPageRouteModule.handleResponse (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.dev.js:71:63567)\n at async handleResponse (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1057:32)\n at async Module.handler (/Users/sepehr/dev/Keep/keep-notes/.next/dev/server/chunks/ssr/0sqx_next_dist_esm_0n1n9n9._.js:1460:20)\n at async DevServer.renderToResponseWithComponentsImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1454:9)\n at async DevServer.renderPageComponent (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1506:24)\n at async DevServer.renderToResponseImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1556:32)\n at async DevServer.pipeImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:1043:25)\n at async NextNodeServer.handleCatchallRenderRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/next-server.js:338:17)\n at async DevServer.handleRequestImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/base-server.js:934:17)\n at async /Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:394:20\n at async Span.traceAsyncFn (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/trace/trace.js:164:20)\n at async DevServer.handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/dev/next-dev-server.js:390:24)\n at async invokeRender (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:253:21)\n at async handleRequest (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:452:24)\n at async requestHandlerImpl (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/router-server.js:501:13)\n at async Server.requestListener (/Users/sepehr/dev/Keep/keep-notes/node_modules/next/dist/server/lib/start-server.js:225:13)",
|
||||
"failedMessage": "non je parle de Momento MCP ",
|
||||
"conversationId": "cmo3gb6xu001x84i0vg5qrw0q",
|
||||
"data": {
|
||||
"error": {
|
||||
"message": "Invalid Responses API request",
|
||||
"code": "invalid_prompt"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default async function AdminLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50 dark:bg-zinc-950">
|
||||
<div className="flex h-full bg-gray-50 dark:bg-zinc-950">
|
||||
<AdminSidebar />
|
||||
<AdminContentArea>{children}</AdminContentArea>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { updateSystemConfig, testEmail } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { getCustomModels, getCustomEmbeddingModels } from '@/app/actions/custom-provider'
|
||||
import { toast } from 'sonner'
|
||||
@@ -38,43 +39,55 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const [smtpSecure, setSmtpSecure] = useState(config.SMTP_SECURE === 'true')
|
||||
const [smtpIgnoreCert, setSmtpIgnoreCert] = useState(config.SMTP_IGNORE_CERT === 'true')
|
||||
|
||||
// AI Provider state - separated for tags and embeddings
|
||||
// Agent tools state
|
||||
const [webSearchProvider, setWebSearchProvider] = useState(config.WEB_SEARCH_PROVIDER || 'searxng')
|
||||
|
||||
// Email provider state
|
||||
const [emailProvider, setEmailProvider] = useState<'resend' | 'smtp'>(config.EMAIL_PROVIDER as 'resend' | 'smtp' || (config.RESEND_API_KEY ? 'resend' : 'smtp'))
|
||||
const [emailTestResult, setEmailTestResult] = useState<{ provider: 'resend' | 'smtp'; success: boolean; message?: string } | null>(null)
|
||||
|
||||
// AI Provider state - separated for tags, embeddings, and chat
|
||||
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
|
||||
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
|
||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||
|
||||
// Selected Models State (Controlled Inputs)
|
||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||
|
||||
|
||||
// Dynamic Models State
|
||||
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
|
||||
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
|
||||
const [ollamaChatModels, setOllamaChatModels] = useState<string[]>([])
|
||||
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
|
||||
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
|
||||
const [isLoadingChatModels, setIsLoadingChatModels] = useState(false)
|
||||
|
||||
// Custom provider dynamic models
|
||||
const [customTagsModels, setCustomTagsModels] = useState<string[]>([])
|
||||
const [customEmbeddingsModels, setCustomEmbeddingsModels] = useState<string[]>([])
|
||||
const [customChatModels, setCustomChatModels] = useState<string[]>([])
|
||||
const [isLoadingCustomTagsModels, setIsLoadingCustomTagsModels] = useState(false)
|
||||
const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false)
|
||||
const [customTagsSearch, setCustomTagsSearch] = useState('')
|
||||
const [customEmbeddingsSearch, setCustomEmbeddingsSearch] = useState('')
|
||||
|
||||
const [isLoadingCustomChatModels, setIsLoadingCustomChatModels] = useState(false)
|
||||
|
||||
// Fetch Ollama models
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingTagsModels(true)
|
||||
else setIsLoadingEmbeddingsModels(true)
|
||||
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(true)
|
||||
else setIsLoadingChatModels(true)
|
||||
|
||||
try {
|
||||
const result = await getOllamaModels(url)
|
||||
|
||||
if (result.success) {
|
||||
if (type === 'tags') setOllamaTagsModels(result.models)
|
||||
else setOllamaEmbeddingsModels(result.models)
|
||||
else if (type === 'embeddings') setOllamaEmbeddingsModels(result.models)
|
||||
else setOllamaChatModels(result.models)
|
||||
} else {
|
||||
toast.error(`Failed to fetch Ollama models: ${result.error}`)
|
||||
}
|
||||
@@ -83,16 +96,18 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
toast.error('Failed to fetch Ollama models')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingTagsModels(false)
|
||||
else setIsLoadingEmbeddingsModels(false)
|
||||
else if (type === 'embeddings') setIsLoadingEmbeddingsModels(false)
|
||||
else setIsLoadingChatModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch Custom provider models — tags use /v1/models, embeddings use /v1/embeddings/models
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings', url: string, apiKey?: string) => {
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings' | 'chat', url: string, apiKey?: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(true)
|
||||
else setIsLoadingCustomEmbeddingsModels(true)
|
||||
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(true)
|
||||
else setIsLoadingCustomChatModels(true)
|
||||
|
||||
try {
|
||||
const result = type === 'embeddings'
|
||||
@@ -101,7 +116,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
if (result.success && result.models.length > 0) {
|
||||
if (type === 'tags') setCustomTagsModels(result.models)
|
||||
else setCustomEmbeddingsModels(result.models)
|
||||
else if (type === 'embeddings') setCustomEmbeddingsModels(result.models)
|
||||
else setCustomChatModels(result.models)
|
||||
} else {
|
||||
toast.error(`Impossible de récupérer les modèles : ${result.error}`)
|
||||
}
|
||||
@@ -110,7 +126,8 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
toast.error('Erreur lors de la récupération des modèles')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(false)
|
||||
else setIsLoadingCustomEmbeddingsModels(false)
|
||||
else if (type === 'embeddings') setIsLoadingCustomEmbeddingsModels(false)
|
||||
else setIsLoadingCustomChatModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -144,6 +161,18 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tagsProvider])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('chat', url)
|
||||
} else if (chatProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('chat', url, key)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatProvider])
|
||||
|
||||
const handleSaveSecurity = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
@@ -205,6 +234,27 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
|
||||
// Chat provider config
|
||||
const chatProv = formData.get('AI_PROVIDER_CHAT') as AIProvider
|
||||
if (chatProv) {
|
||||
data.AI_PROVIDER_CHAT = chatProv
|
||||
|
||||
const chatModel = formData.get(`AI_MODEL_CHAT_${chatProv.toUpperCase()}`) as string
|
||||
if (chatModel) data.AI_MODEL_CHAT = chatModel
|
||||
|
||||
if (chatProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('OLLAMA_BASE_URL_CHAT') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
|
||||
} else if (chatProv === 'openai') {
|
||||
const openaiKey = formData.get('OPENAI_API_KEY') as string
|
||||
if (openaiKey) data.OPENAI_API_KEY = openaiKey
|
||||
} else if (chatProv === 'custom') {
|
||||
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_CHAT') as string
|
||||
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_CHAT') as string
|
||||
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
|
||||
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
@@ -220,16 +270,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSMTP = async (formData: FormData) => {
|
||||
const handleSaveEmail = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
SMTP_HOST: formData.get('SMTP_HOST') as string,
|
||||
SMTP_PORT: formData.get('SMTP_PORT') as string,
|
||||
SMTP_USER: formData.get('SMTP_USER') as string,
|
||||
SMTP_PASS: formData.get('SMTP_PASS') as string,
|
||||
SMTP_FROM: formData.get('SMTP_FROM') as string,
|
||||
SMTP_IGNORE_CERT: smtpIgnoreCert ? 'true' : 'false',
|
||||
SMTP_SECURE: smtpSecure ? 'true' : 'false',
|
||||
const data: Record<string, string> = { EMAIL_PROVIDER: emailProvider }
|
||||
|
||||
if (emailProvider === 'resend') {
|
||||
const key = formData.get('RESEND_API_KEY') as string
|
||||
if (key) data.RESEND_API_KEY = key
|
||||
} else {
|
||||
data.SMTP_HOST = formData.get('SMTP_HOST') as string
|
||||
data.SMTP_PORT = formData.get('SMTP_PORT') as string
|
||||
data.SMTP_USER = formData.get('SMTP_USER') as string
|
||||
data.SMTP_PASS = formData.get('SMTP_PASS') as string
|
||||
data.SMTP_FROM = formData.get('SMTP_FROM') as string
|
||||
data.SMTP_IGNORE_CERT = smtpIgnoreCert ? 'true' : 'false'
|
||||
data.SMTP_SECURE = smtpSecure ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
@@ -245,19 +300,41 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const handleTestEmail = async () => {
|
||||
setIsTesting(true)
|
||||
try {
|
||||
const result: any = await testSMTP()
|
||||
const result: any = await testEmail(emailProvider)
|
||||
if (result.success) {
|
||||
toast.success(t('admin.smtp.testSuccess'))
|
||||
setEmailTestResult({ provider: emailProvider, success: true })
|
||||
} else {
|
||||
toast.error(t('admin.smtp.testFailed', { error: result.error }))
|
||||
setEmailTestResult({ provider: emailProvider, success: false, message: result.error })
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(t('general.error') + ': ' + e.message)
|
||||
setEmailTestResult({ provider: emailProvider, success: false, message: e.message })
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveTools = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
WEB_SEARCH_PROVIDER: formData.get('WEB_SEARCH_PROVIDER') as string || 'searxng',
|
||||
SEARXNG_URL: formData.get('SEARXNG_URL') as string || '',
|
||||
BRAVE_SEARCH_API_KEY: formData.get('BRAVE_SEARCH_API_KEY') as string || '',
|
||||
JINA_API_KEY: formData.get('JINA_API_KEY') as string || '',
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
if (result.error) {
|
||||
toast.error(t('admin.tools.updateFailed'))
|
||||
} else {
|
||||
toast.success(t('admin.tools.updateSuccess'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -433,35 +510,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
{customTagsModels.length > 0 && (
|
||||
<Input
|
||||
placeholder="Rechercher un modèle..."
|
||||
value={customTagsSearch}
|
||||
onChange={(e) => setCustomTagsSearch(e.target.value)}
|
||||
className="mb-1"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
id="AI_MODEL_TAGS_CUSTOM"
|
||||
name="AI_MODEL_TAGS_CUSTOM"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_TAGS_CUSTOM" value={selectedTagsModel} />
|
||||
<Combobox
|
||||
options={customTagsModels.length > 0
|
||||
? customTagsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedTagsModel
|
||||
? [{ value: selectedTagsModel, label: selectedTagsModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className={`flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${customTagsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
|
||||
size={customTagsModels.length > 0 ? Math.min(8, customTagsModels.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase())).length) : 1}
|
||||
>
|
||||
{customTagsModels.length > 0 ? (
|
||||
customTagsModels
|
||||
.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase()))
|
||||
.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
selectedTagsModel
|
||||
? <option value={selectedTagsModel}>{selectedTagsModel}</option>
|
||||
: <option value="" disabled>Cliquez sur ↺ pour récupérer les modèles</option>
|
||||
)}
|
||||
</select>
|
||||
onChange={setSelectedTagsModel}
|
||||
placeholder={selectedTagsModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomTagsModels
|
||||
? 'Récupération des modèles...'
|
||||
@@ -619,35 +682,21 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
{customEmbeddingsModels.length > 0 && (
|
||||
<Input
|
||||
placeholder="Rechercher un modèle..."
|
||||
value={customEmbeddingsSearch}
|
||||
onChange={(e) => setCustomEmbeddingsSearch(e.target.value)}
|
||||
className="mb-1"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
name="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_EMBEDDING_CUSTOM" value={selectedEmbeddingModel} />
|
||||
<Combobox
|
||||
options={customEmbeddingsModels.length > 0
|
||||
? customEmbeddingsModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedEmbeddingModel
|
||||
? [{ value: selectedEmbeddingModel, label: selectedEmbeddingModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className={`flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${customEmbeddingsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
|
||||
size={customEmbeddingsModels.length > 0 ? Math.min(8, customEmbeddingsModels.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase())).length) : 1}
|
||||
>
|
||||
{customEmbeddingsModels.length > 0 ? (
|
||||
customEmbeddingsModels
|
||||
.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase()))
|
||||
.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
selectedEmbeddingModel
|
||||
? <option value={selectedEmbeddingModel}>{selectedEmbeddingModel}</option>
|
||||
: <option value="" disabled>Cliquez sur ↺ pour récupérer les modèles</option>
|
||||
)}
|
||||
</select>
|
||||
onChange={setSelectedEmbeddingModel}
|
||||
placeholder={selectedEmbeddingModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomEmbeddingsModels
|
||||
? 'Récupération des modèles...'
|
||||
@@ -659,6 +708,171 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat Provider Section */}
|
||||
<div className="space-y-4 p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<span className="text-blue-600">💬</span> {t('admin.ai.chatProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.chatDescription')}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_CHAT">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_CHAT"
|
||||
name="AI_PROVIDER_CHAT"
|
||||
value={chatProvider}
|
||||
onChange={(e) => {
|
||||
const newProvider = e.target.value as AIProvider
|
||||
setChatProvider(newProvider)
|
||||
const defaultModels: Record<string, string> = {
|
||||
ollama: '',
|
||||
openai: MODELS_2026.openai.tags[0],
|
||||
custom: '',
|
||||
}
|
||||
setSelectedChatModel(defaultModels[newProvider] || '')
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
|
||||
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
|
||||
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{chatProvider === 'ollama' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OLLAMA_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="OLLAMA_BASE_URL_CHAT"
|
||||
name="OLLAMA_BASE_URL_CHAT"
|
||||
defaultValue={config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('OLLAMA_BASE_URL_CHAT') as HTMLInputElement
|
||||
fetchOllamaModels('chat', input.value)
|
||||
}}
|
||||
disabled={isLoadingChatModels}
|
||||
title="Refresh Models"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingChatModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_CHAT_OLLAMA">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_CHAT_OLLAMA"
|
||||
name="AI_MODEL_CHAT_OLLAMA"
|
||||
value={selectedChatModel}
|
||||
onChange={(e) => setSelectedChatModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{ollamaChatModels.length > 0 ? (
|
||||
ollamaChatModels.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
<option value={selectedChatModel || 'granite4:latest'}>{selectedChatModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingChatModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatProvider === 'openai' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="OPENAI_API_KEY_CHAT" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_CHAT_OPENAI">{t('admin.ai.model')}</Label>
|
||||
<select
|
||||
id="AI_MODEL_CHAT_OPENAI"
|
||||
name="AI_MODEL_CHAT_OPENAI"
|
||||
value={selectedChatModel}
|
||||
onChange={(e) => setSelectedChatModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MODELS_2026.openai.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} • <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chatProvider === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_CHAT">{t('admin.ai.baseUrl')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="CUSTOM_OPENAI_BASE_URL_CHAT"
|
||||
name="CUSTOM_OPENAI_BASE_URL_CHAT"
|
||||
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_CHAT') as HTMLInputElement
|
||||
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_CHAT') as HTMLInputElement
|
||||
fetchCustomModels('chat', urlInput.value, keyInput.value)
|
||||
}}
|
||||
disabled={isLoadingCustomChatModels}
|
||||
title="Récupérer les modèles"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingCustomChatModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_CHAT">{t('admin.ai.apiKey')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_API_KEY_CHAT" name="CUSTOM_OPENAI_API_KEY_CHAT" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('admin.ai.model')}</Label>
|
||||
<input type="hidden" name="AI_MODEL_CHAT_CUSTOM" value={selectedChatModel} />
|
||||
<Combobox
|
||||
options={customChatModels.length > 0
|
||||
? customChatModels.map((m) => ({ value: m, label: m }))
|
||||
: selectedChatModel
|
||||
? [{ value: selectedChatModel, label: selectedChatModel }]
|
||||
: []
|
||||
}
|
||||
value={selectedChatModel}
|
||||
onChange={setSelectedChatModel}
|
||||
placeholder={selectedChatModel || 'Cliquez sur ↺ pour charger les modèles'}
|
||||
searchPlaceholder="Rechercher un modèle..."
|
||||
emptyMessage="Aucun modèle. Cliquez sur ↺"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomChatModels
|
||||
? 'Récupération des modèles...'
|
||||
: customChatModels.length > 0
|
||||
? `${customChatModels.length} modèle(s) disponible(s)`
|
||||
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
@@ -675,73 +889,195 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.smtp.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.smtp.description')}</CardDescription>
|
||||
<CardTitle>{t('admin.email.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.email.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveSMTP(new FormData(e.currentTarget)) }}>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEmail(new FormData(e.currentTarget)) }}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
|
||||
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
|
||||
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('admin.email.provider')}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailProvider('resend')}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
emailProvider === 'resend'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmailProvider('smtp')}
|
||||
className={`flex-1 px-4 py-2.5 rounded-lg border text-sm font-medium transition-colors ${
|
||||
emailProvider === 'smtp'
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-background text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
SMTP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
|
||||
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
|
||||
{/* Email service status */}
|
||||
<div className="rounded-lg border bg-muted/30 p-3 space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{t('admin.email.status')}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${config.RESEND_API_KEY ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
<span className={config.RESEND_API_KEY ? 'text-green-700' : 'text-slate-400'}>Resend</span>
|
||||
{config.RESEND_API_KEY && <span className="text-xs text-muted-foreground">({t('admin.email.keySet')})</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${config.SMTP_HOST ? 'bg-green-500' : 'bg-slate-300'}`} />
|
||||
<span className={config.SMTP_HOST ? 'text-green-700' : 'text-slate-400'}>SMTP</span>
|
||||
{config.SMTP_HOST && <span className="text-xs text-muted-foreground">({config.SMTP_HOST}:{config.SMTP_PORT || '587'})</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-primary">
|
||||
{t('admin.email.activeProvider')}: {emailProvider === 'resend' ? 'Resend' : 'SMTP'}
|
||||
</div>
|
||||
{emailTestResult && (
|
||||
<div className={`flex items-center gap-1.5 text-xs pt-1 border-t ${emailTestResult.success ? 'text-green-600' : 'text-red-500'}`}>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${emailTestResult.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span>
|
||||
{emailTestResult.provider === 'resend' ? 'Resend' : 'SMTP'} — {emailTestResult.success ? t('admin.email.testOk') : `${t('admin.email.testFail')}: ${emailTestResult.message || ''}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
|
||||
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
|
||||
</div>
|
||||
{emailProvider === 'resend' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="RESEND_API_KEY" className="text-sm font-medium">{t('admin.resend.apiKey')}</label>
|
||||
<Input id="RESEND_API_KEY" name="RESEND_API_KEY" type="password" defaultValue={config.RESEND_API_KEY || ''} placeholder="re_..." />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.resend.apiKeyHint')}</p>
|
||||
</div>
|
||||
{config.RESEND_API_KEY && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
|
||||
{t('admin.resend.configured')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
|
||||
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
|
||||
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
|
||||
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
|
||||
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_SECURE"
|
||||
checked={smtpSecure}
|
||||
onCheckedChange={(c) => setSmtpSecure(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_SECURE"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('admin.smtp.forceSSL')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
|
||||
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_IGNORE_CERT"
|
||||
checked={smtpIgnoreCert}
|
||||
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_IGNORE_CERT"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
||||
>
|
||||
{t('admin.smtp.ignoreCertErrors')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
|
||||
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_SECURE"
|
||||
checked={smtpSecure}
|
||||
onCheckedChange={(c) => setSmtpSecure(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_SECURE"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('admin.smtp.forceSSL')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="SMTP_IGNORE_CERT"
|
||||
checked={smtpIgnoreCert}
|
||||
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="SMTP_IGNORE_CERT"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
|
||||
>
|
||||
{t('admin.smtp.ignoreCertErrors')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.email.saveSettings')}</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.tools.title')}</CardTitle>
|
||||
<CardDescription>{t('admin.tools.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveTools(new FormData(e.currentTarget)) }}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="WEB_SEARCH_PROVIDER" className="text-sm font-medium">{t('admin.tools.searchProvider')}</label>
|
||||
<select
|
||||
id="WEB_SEARCH_PROVIDER"
|
||||
name="WEB_SEARCH_PROVIDER"
|
||||
value={webSearchProvider}
|
||||
onChange={(e) => setWebSearchProvider(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="searxng">{t('admin.tools.searxng')}</option>
|
||||
<option value="brave">{t('admin.tools.brave')}</option>
|
||||
<option value="both">{t('admin.tools.both')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(webSearchProvider === 'searxng' || webSearchProvider === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="SEARXNG_URL" className="text-sm font-medium">{t('admin.tools.searxngUrl')}</label>
|
||||
<Input id="SEARXNG_URL" name="SEARXNG_URL" defaultValue={config.SEARXNG_URL || 'http://localhost:8080'} placeholder="http://localhost:8080" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(webSearchProvider === 'brave' || webSearchProvider === 'both') && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="BRAVE_SEARCH_API_KEY" className="text-sm font-medium">{t('admin.tools.braveKey')}</label>
|
||||
<Input id="BRAVE_SEARCH_API_KEY" name="BRAVE_SEARCH_API_KEY" type="password" defaultValue={config.BRAVE_SEARCH_API_KEY || ''} placeholder="BSA-..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="JINA_API_KEY" className="text-sm font-medium">{t('admin.tools.jinaKey')}</label>
|
||||
<Input id="JINA_API_KEY" name="JINA_API_KEY" type="password" defaultValue={config.JINA_API_KEY || ''} placeholder={t('admin.tools.jinaKeyOptional')} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.tools.jinaKeyDescription')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.tools.saveSettings')}</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface AgentItem {
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
_count: { actions: number }
|
||||
actions: { id: string; status: string; createdAt: string | Date }[]
|
||||
notebook?: { id: string; name: string; icon?: string | null } | null
|
||||
@@ -118,6 +119,7 @@ export function AgentsPageClient({
|
||||
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
||||
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
|
||||
notifyEmail: formData.get('notifyEmail') === 'true',
|
||||
includeImages: formData.get('includeImages') === 'true',
|
||||
}
|
||||
|
||||
if (editingAgent) {
|
||||
|
||||
33
keep-notes/app/(main)/agents/page.tsx
Normal file
33
keep-notes/app/(main)/agents/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAgents } from '@/app/actions/agent-actions'
|
||||
import { AgentsPageClient } from './agents-page-client'
|
||||
|
||||
export default async function AgentsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [agents, notebooks] = await Promise.all([
|
||||
getAgents(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50/50">
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AgentsPageClient
|
||||
agents={agents}
|
||||
notebooks={notebooks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
keep-notes/app/(main)/chat/page.tsx
Normal file
36
keep-notes/app/(main)/chat/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ChatContainer } from '@/components/chat/chat-container'
|
||||
import { getConversations } from '@/app/actions/chat-actions'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat IA | Memento',
|
||||
description: 'Discutez avec vos notes et vos agents IA',
|
||||
}
|
||||
|
||||
export default async function ChatPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch initial data
|
||||
const [conversations, notebooks] = await Promise.all([
|
||||
getConversations(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
keep-notes/app/(main)/lab/page.tsx
Normal file
56
keep-notes/app/(main)/lab/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCanvases, createCanvas } from '@/app/actions/canvas-actions'
|
||||
import { LabHeader } from '@/components/lab/lab-header'
|
||||
import { CanvasWrapper } from '@/components/lab/canvas-wrapper'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Le Lab | Memento',
|
||||
description: 'Visualisez et connectez vos idées sur un canvas interactif',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export default async function LabPage(props: {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const id = searchParams.id
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const canvases = await getCanvases()
|
||||
|
||||
// Resolve current canvas correctly
|
||||
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
|
||||
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
||||
|
||||
// Wrapper for server action creation
|
||||
async function handleCreate() {
|
||||
'use server'
|
||||
const newCanvas = await createCanvas()
|
||||
redirect(`/lab?id=${newCanvas.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50 dark:bg-[#1a1c22] overflow-hidden">
|
||||
<LabHeader
|
||||
canvases={canvases}
|
||||
currentCanvasId={currentCanvasId ?? null}
|
||||
onCreateCanvas={handleCreate}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<CanvasWrapper
|
||||
key={currentCanvasId || 'new'}
|
||||
canvasId={currentCanvas?.id}
|
||||
name={currentCanvas?.name || "Nouvel Espace de Pensée"}
|
||||
initialData={currentCanvas?.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,7 @@ import { getAllNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { HomeClient } from '@/components/home-client'
|
||||
|
||||
/**
|
||||
* Page principale — Server Component.
|
||||
* Les notes et settings sont chargés côté serveur en parallèle,
|
||||
* éliminant le spinner de chargement initial et améliorant le TTI.
|
||||
*/
|
||||
export default async function HomePage() {
|
||||
// Charge notes + settings en parallèle côté serveur
|
||||
const [allNotes, settings] = await Promise.all([
|
||||
getAllNotes(),
|
||||
getAISettings(),
|
||||
|
||||
@@ -10,12 +10,14 @@ import { useLanguage } from '@/lib/i18n'
|
||||
interface AppearanceSettingsFormProps {
|
||||
initialTheme: string
|
||||
initialFontSize: string
|
||||
initialCardSizeMode?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize }: AppearanceSettingsFormProps) {
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize, initialCardSizeMode = 'variable' }: AppearanceSettingsFormProps) {
|
||||
const router = useRouter()
|
||||
const [theme, setTheme] = useState(initialTheme)
|
||||
const [fontSize, setFontSize] = useState(initialFontSize)
|
||||
const [cardSizeMode, setCardSizeMode] = useState(initialCardSizeMode)
|
||||
const { t } = useLanguage()
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
@@ -55,6 +57,12 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
await updateAI({ fontSize: value as any })
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
setCardSizeMode(value)
|
||||
localStorage.setItem('card-size-mode', value)
|
||||
await updateUser({ cardSizeMode: value as 'variable' | 'uniform' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -102,6 +110,23 @@ export function AppearanceSettingsForm({ initialTheme, initialFontSize }: Appear
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@ interface AppearanceSettingsClientProps {
|
||||
initialFontSize: string
|
||||
initialTheme: string
|
||||
initialNotesViewMode: 'masonry' | 'tabs'
|
||||
initialCardSizeMode?: 'variable' | 'uniform'
|
||||
}
|
||||
|
||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode }: AppearanceSettingsClientProps) {
|
||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable' }: AppearanceSettingsClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
|
||||
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
@@ -59,6 +61,14 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
const mode = value === 'uniform' ? 'uniform' : 'variable'
|
||||
setCardSizeMode(mode)
|
||||
localStorage.setItem('card-size-mode', mode)
|
||||
await updateUserSettings({ cardSizeMode: mode })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -122,6 +132,23 @@ export function AppearanceSettingsClient({ initialFontSize, initialTheme, initia
|
||||
onChange={handleNotesViewChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function AppearanceSettingsPage() {
|
||||
initialFontSize={aiSettings.fontSize}
|
||||
initialTheme={userSettings.theme}
|
||||
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
|
||||
initialCardSizeMode={userSettings.cardSizeMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ interface GeneralSettingsClientProps {
|
||||
preferredLanguage: string
|
||||
emailNotifications: boolean
|
||||
desktopNotifications: boolean
|
||||
anonymousAnalytics: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +21,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||
const [anonymousAnalytics, setAnonymousAnalytics] = useState(initialSettings.anonymousAnalytics ?? false)
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
@@ -52,12 +50,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
|
||||
setAnonymousAnalytics(enabled)
|
||||
await updateAISettings({ anonymousAnalytics: enabled })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -116,19 +108,6 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
onChange={handleDesktopNotificationsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.privacy')}
|
||||
icon={<span className="text-2xl">🔒</span>}
|
||||
description={t('settings.privacyDesc')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('settings.anonymousAnalytics')}
|
||||
description={t('settings.anonymousAnalyticsDesc')}
|
||||
checked={anonymousAnalytics}
|
||||
onChange={handleAnonymousAnalyticsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { getTrashedNotes } from '@/app/actions/notes'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
import { TrashHeader } from '@/components/trash-header'
|
||||
import { TrashEmptyState } from './trash-empty-state'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function TrashPage() {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashContent />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
export default async function TrashPage() {
|
||||
const notes = await getTrashedNotes()
|
||||
|
||||
function TrashContent() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
|
||||
<Trash2 className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-2">{t('trash.empty')}</h2>
|
||||
<p className="max-w-md text-sm opacity-80">
|
||||
{t('trash.restore')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashHeader noteCount={notes.length} />
|
||||
{notes.length > 0 ? (
|
||||
<MasonryGrid notes={notes} isTrashView />
|
||||
) : (
|
||||
<TrashEmptyState />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
20
keep-notes/app/(main)/trash/trash-empty-state.tsx
Normal file
20
keep-notes/app/(main)/trash/trash-empty-state.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function TrashEmptyState() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
|
||||
<Trash2 className="w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-2">{t('trash.empty')}</h2>
|
||||
<p className="max-w-md text-sm opacity-80">
|
||||
{t('trash.emptyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,17 +13,25 @@ async function checkAdmin() {
|
||||
return session
|
||||
}
|
||||
|
||||
export async function testSMTP() {
|
||||
export async function testEmail(provider: 'resend' | 'smtp' = 'smtp') {
|
||||
const session = await checkAdmin()
|
||||
const email = session.user?.email
|
||||
|
||||
if (!email) throw new Error("No admin email found")
|
||||
|
||||
const subject = provider === 'resend'
|
||||
? "Memento Resend Test"
|
||||
: "Memento SMTP Test"
|
||||
|
||||
const html = provider === 'resend'
|
||||
? "<p>This is a test email from your Memento instance. <strong>Resend is working!</strong></p>"
|
||||
: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
|
||||
|
||||
const result = await sendEmail({
|
||||
to: email,
|
||||
subject: "Memento SMTP Test",
|
||||
html: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
|
||||
})
|
||||
subject,
|
||||
html,
|
||||
}, provider)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { executeAgent } from '@/lib/ai/services/agent-executor.service'
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
@@ -21,6 +20,10 @@ export async function createAgent(data: {
|
||||
sourceNotebookId?: string
|
||||
targetNotebookId?: string
|
||||
frequency?: string
|
||||
tools?: string[]
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
@@ -38,6 +41,10 @@ export async function createAgent(data: {
|
||||
sourceNotebookId: data.sourceNotebookId || null,
|
||||
targetNotebookId: data.targetNotebookId || null,
|
||||
frequency: data.frequency || 'manual',
|
||||
tools: data.tools ? JSON.stringify(data.tools) : '[]',
|
||||
maxSteps: data.maxSteps || 10,
|
||||
notifyEmail: data.notifyEmail || false,
|
||||
includeImages: data.includeImages || false,
|
||||
userId: session.user.id,
|
||||
}
|
||||
})
|
||||
@@ -60,6 +67,10 @@ export async function updateAgent(id: string, data: {
|
||||
targetNotebookId?: string | null
|
||||
frequency?: string
|
||||
isEnabled?: boolean
|
||||
tools?: string[]
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
@@ -82,6 +93,10 @@ export async function updateAgent(id: string, data: {
|
||||
if (data.targetNotebookId !== undefined) updateData.targetNotebookId = data.targetNotebookId
|
||||
if (data.frequency !== undefined) updateData.frequency = data.frequency
|
||||
if (data.isEnabled !== undefined) updateData.isEnabled = data.isEnabled
|
||||
if (data.tools !== undefined) updateData.tools = JSON.stringify(data.tools)
|
||||
if (data.maxSteps !== undefined) updateData.maxSteps = data.maxSteps
|
||||
if (data.notifyEmail !== undefined) updateData.notifyEmail = data.notifyEmail
|
||||
if (data.includeImages !== undefined) updateData.includeImages = data.includeImages
|
||||
|
||||
const agent = await prisma.agent.update({
|
||||
where: { id },
|
||||
@@ -155,6 +170,7 @@ export async function runAgent(id: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { executeAgent } = await import('@/lib/ai/services/agent-executor.service')
|
||||
const result = await executeAgent(id, session.user.id)
|
||||
revalidatePath('/agents')
|
||||
revalidatePath('/')
|
||||
@@ -182,6 +198,16 @@ export async function getAgentActions(agentId: string) {
|
||||
where: { agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
result: true,
|
||||
log: true,
|
||||
input: true,
|
||||
toolLog: true,
|
||||
tokensUsed: true,
|
||||
createdAt: true,
|
||||
}
|
||||
})
|
||||
return actions
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { sendEmail } from '@/lib/mail'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getEmailTemplate } from '@/lib/email-template'
|
||||
|
||||
@@ -42,11 +43,14 @@ export async function forgotPassword(email: string) {
|
||||
"Reset Password"
|
||||
);
|
||||
|
||||
const sysConfig = await getSystemConfig()
|
||||
const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto'
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your Memento password",
|
||||
html
|
||||
});
|
||||
}, emailProvider);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
111
keep-notes/app/actions/canvas-actions.ts
Normal file
111
keep-notes/app/actions/canvas-actions.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function saveCanvas(id: string | null, name: string, data: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
if (id) {
|
||||
const canvas = await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name, data }
|
||||
})
|
||||
revalidatePath('/lab')
|
||||
return { success: true, canvas }
|
||||
} else {
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name,
|
||||
data,
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
revalidatePath('/lab')
|
||||
return { success: true, canvas }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCanvases() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return []
|
||||
|
||||
return prisma.canvas.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { createdAt: 'asc' }
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCanvasDetails(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
return prisma.canvas.findUnique({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCanvas(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.canvas.delete({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function renameCanvas(id: string, name: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name }
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function createCanvas(lang?: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const count = await prisma.canvas.count({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
|
||||
const defaultNames: Record<string, string> = {
|
||||
en: `Space ${count + 1}`,
|
||||
fr: `Espace ${count + 1}`,
|
||||
fa: `فضای ${count + 1}`,
|
||||
es: `Espacio ${count + 1}`,
|
||||
de: `Bereich ${count + 1}`,
|
||||
it: `Spazio ${count + 1}`,
|
||||
pt: `Espaço ${count + 1}`,
|
||||
ru: `Пространство ${count + 1}`,
|
||||
ja: `スペース ${count + 1}`,
|
||||
ko: `공간 ${count + 1}`,
|
||||
zh: `空间 ${count + 1}`,
|
||||
ar: `مساحة ${count + 1}`,
|
||||
hi: `स्थान ${count + 1}`,
|
||||
nl: `Ruimte ${count + 1}`,
|
||||
pl: `Przestrzeń ${count + 1}`,
|
||||
}
|
||||
|
||||
const newCanvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: defaultNames[lang || 'en'] || defaultNames.en,
|
||||
data: JSON.stringify({}),
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
|
||||
revalidatePath('/lab')
|
||||
return newCanvas
|
||||
}
|
||||
74
keep-notes/app/actions/chat-actions.ts
Normal file
74
keep-notes/app/actions/chat-actions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use server'
|
||||
|
||||
import { chatService } from '@/lib/ai/services'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
/**
|
||||
* Create a new empty conversation and return its id.
|
||||
* Called before streaming so the client knows the conversationId upfront.
|
||||
*/
|
||||
export async function createConversation(title: string, notebookId?: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
const conversation = await prisma.conversation.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
notebookId: notebookId || null,
|
||||
title: title.substring(0, 80) + (title.length > 80 ? '...' : ''),
|
||||
},
|
||||
})
|
||||
|
||||
revalidatePath('/chat')
|
||||
return { id: conversation.id, title: conversation.title }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use the streaming API route /api/chat instead.
|
||||
* Kept for backward compatibility with the debug route.
|
||||
*/
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
notebookId?: string
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
try {
|
||||
const result = await chatService.chat(message, conversationId, notebookId)
|
||||
revalidatePath('/chat')
|
||||
return { success: true, ...result }
|
||||
} catch (error: any) {
|
||||
console.error('[ChatAction] Error:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getConversations() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return []
|
||||
|
||||
return chatService.listConversations(session.user.id)
|
||||
}
|
||||
|
||||
export async function getConversationDetails(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
|
||||
return chatService.getHistory(id)
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
await prisma.conversation.delete({
|
||||
where: { id, userId: session.user.id }
|
||||
})
|
||||
|
||||
revalidatePath('/chat')
|
||||
return { success: true }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
|
||||
|
||||
/**
|
||||
* Champs sélectionnés pour les listes de notes (sans embedding pour économiser ~6KB/note).
|
||||
@@ -20,6 +21,7 @@ const NOTE_LIST_SELECT = {
|
||||
color: true,
|
||||
isPinned: true,
|
||||
isArchived: true,
|
||||
trashedAt: true,
|
||||
type: true,
|
||||
dismissedFromRecent: true,
|
||||
checkItems: true,
|
||||
@@ -219,6 +221,7 @@ export async function getNotes(includeArchived = false) {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: null,
|
||||
...(includeArchived ? {} : { isArchived: false }),
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
@@ -245,6 +248,7 @@ export async function getNotesWithReminders() {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: null,
|
||||
isArchived: false,
|
||||
reminder: { not: null }
|
||||
},
|
||||
@@ -286,7 +290,8 @@ export async function getArchivedNotes() {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: true
|
||||
isArchived: true,
|
||||
trashedAt: null
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
@@ -321,6 +326,7 @@ export async function searchNotes(query: string, useSemantic: boolean = false, n
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
OR: [
|
||||
{ title: { contains: query } },
|
||||
{ content: { contains: query } },
|
||||
@@ -349,6 +355,7 @@ async function semanticSearch(query: string, userId: string, notebookId?: string
|
||||
where: {
|
||||
userId: userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
...(notebookId !== undefined ? { notebookId } : {})
|
||||
},
|
||||
include: { noteEmbedding: true }
|
||||
@@ -650,17 +657,16 @@ export async function updateNote(id: string, data: {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a note
|
||||
// Soft-delete a note (move to trash)
|
||||
export async function deleteNote(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
await prisma.note.delete({ where: { id, userId: session.user.id } })
|
||||
|
||||
// Sync labels with empty array to trigger cleanup of any orphans
|
||||
// The syncLabels function will scan all remaining notes and clean up unused labels
|
||||
await syncLabels(session.user.id, [])
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
@@ -670,6 +676,192 @@ export async function deleteNote(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Trash actions
|
||||
export async function trashNote(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error trashing note:', error)
|
||||
throw new Error('Failed to trash note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreNote(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { trashedAt: null }
|
||||
})
|
||||
revalidatePath('/')
|
||||
revalidatePath('/trash')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error restoring note:', error)
|
||||
throw new Error('Failed to restore note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrashedNotes() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return [];
|
||||
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
orderBy: { trashedAt: 'desc' }
|
||||
})
|
||||
|
||||
return notes.map(parseNote)
|
||||
} catch (error) {
|
||||
console.error('Error fetching trashed notes:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function permanentDeleteNote(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
// Fetch images before deleting so we can clean up files
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id, userId: session.user.id },
|
||||
select: { images: true }
|
||||
})
|
||||
const imageUrls = parseImageUrls(note?.images ?? null)
|
||||
|
||||
await prisma.note.delete({ where: { id, userId: session.user.id } })
|
||||
|
||||
// Clean up orphaned image files (safe: skips if referenced by other notes)
|
||||
if (imageUrls.length > 0) {
|
||||
await cleanupNoteImages(id, imageUrls)
|
||||
}
|
||||
|
||||
await syncLabels(session.user.id, [])
|
||||
revalidatePath('/trash')
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error permanently deleting note:', error)
|
||||
throw new Error('Failed to permanently delete note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function emptyTrash() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
// Fetch trashed notes with images before deleting
|
||||
const trashedNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
},
|
||||
select: { id: true, images: true }
|
||||
})
|
||||
|
||||
await prisma.note.deleteMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image files for all deleted notes
|
||||
for (const note of trashedNotes) {
|
||||
const imageUrls = parseImageUrls(note.images)
|
||||
if (imageUrls.length > 0) {
|
||||
await cleanupNoteImages(note.id, imageUrls)
|
||||
}
|
||||
}
|
||||
|
||||
await syncLabels(session.user.id, [])
|
||||
revalidatePath('/trash')
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error emptying trash:', error)
|
||||
throw new Error('Failed to empty trash')
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeImageFromNote(noteId: string, imageIndex: number) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) throw new Error('Unauthorized')
|
||||
|
||||
try {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
select: { images: true },
|
||||
})
|
||||
if (!note) throw new Error('Note not found')
|
||||
|
||||
const imageUrls = parseImageUrls(note.images)
|
||||
if (imageIndex < 0 || imageIndex >= imageUrls.length) throw new Error('Invalid image index')
|
||||
|
||||
const removedUrl = imageUrls[imageIndex]
|
||||
const newImages = imageUrls.filter((_, i) => i !== imageIndex)
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { images: newImages.length > 0 ? JSON.stringify(newImages) : null },
|
||||
})
|
||||
|
||||
// Clean up file if no other note references it
|
||||
await deleteImageFileSafely(removedUrl, noteId)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error removing image:', error)
|
||||
throw new Error('Failed to remove image')
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupOrphanedImages(imageUrls: string[], noteId: string) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return
|
||||
|
||||
try {
|
||||
for (const url of imageUrls) {
|
||||
await deleteImageFileSafely(url, noteId)
|
||||
}
|
||||
} catch {
|
||||
// Silent — best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrashCount() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return 0;
|
||||
|
||||
try {
|
||||
return await prisma.note.count({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
trashedAt: { not: null }
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle functions
|
||||
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
|
||||
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
|
||||
@@ -710,7 +902,7 @@ export async function reorderNotes(draggedId: string, targetId: string) {
|
||||
const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } })
|
||||
if (!draggedNote || !targetNote) throw new Error('Notes not found')
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false },
|
||||
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false, trashedAt: null },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId)
|
||||
@@ -865,11 +1057,12 @@ export async function syncAllEmbeddings() {
|
||||
const userId = session.user.id;
|
||||
let updatedCount = 0;
|
||||
try {
|
||||
const notesToSync = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
const notesToSync = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
trashedAt: null,
|
||||
noteEmbedding: { is: null }
|
||||
}
|
||||
}
|
||||
})
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
for (const note of notesToSync) {
|
||||
@@ -905,6 +1098,7 @@ export async function getAllNotes(includeArchived = false) {
|
||||
prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
trashedAt: null,
|
||||
...(includeArchived ? {} : { isArchived: false }),
|
||||
},
|
||||
select: NOTE_LIST_SELECT,
|
||||
@@ -923,6 +1117,7 @@ export async function getAllNotes(includeArchived = false) {
|
||||
const sharedNotes = acceptedShares
|
||||
.map(share => share.note)
|
||||
.filter(note => includeArchived || !note.isArchived)
|
||||
.map(note => ({ ...note, _isShared: true }))
|
||||
|
||||
return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||||
} catch (error) {
|
||||
@@ -944,6 +1139,7 @@ export async function getPinnedNotes(notebookId?: string) {
|
||||
userId: userId,
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
...(notebookId !== undefined ? { notebookId } : {})
|
||||
},
|
||||
orderBy: [
|
||||
@@ -977,6 +1173,7 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
userId: userId,
|
||||
contentUpdatedAt: { gte: sevenDaysAgo },
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
dismissedFromRecent: false // Filter out dismissed notes
|
||||
},
|
||||
orderBy: { contentUpdatedAt: 'desc' },
|
||||
@@ -1118,8 +1315,20 @@ export async function getNoteCollaborators(noteId: string) {
|
||||
throw new Error('Note not found')
|
||||
}
|
||||
|
||||
// Owner can always see collaborators
|
||||
// Shared users can also see collaborators if they have accepted access
|
||||
if (note.userId !== session.user.id) {
|
||||
throw new Error('You do not have access to this note')
|
||||
const share = await prisma.noteShare.findUnique({
|
||||
where: {
|
||||
noteId_userId: {
|
||||
noteId,
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!share || share.status !== 'accepted') {
|
||||
throw new Error('You do not have access to this note')
|
||||
}
|
||||
}
|
||||
|
||||
// Get all users who have been shared this note (any status)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
cardSizeMode?: 'variable' | 'uniform'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,11 +49,12 @@ const getCachedUserSettings = unstable_cache(
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { theme: true }
|
||||
select: { theme: true, cardSizeMode: true }
|
||||
})
|
||||
|
||||
return {
|
||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue',
|
||||
cardSizeMode: (user?.cardSizeMode || 'variable') as 'variable' | 'uniform'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting user settings:', error)
|
||||
@@ -75,7 +77,8 @@ export async function getUserSettings(userId?: string) {
|
||||
|
||||
if (!id) {
|
||||
return {
|
||||
theme: 'light' as const
|
||||
theme: 'light' as const,
|
||||
cardSizeMode: 'variable' as const
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,18 +25,26 @@ export async function POST(req: NextRequest) {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères)
|
||||
// Détecter la langue du contenu (simple détection basée sur les caractères et mots)
|
||||
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
|
||||
const isPersian = /[\u0600-\u06FF]/.test(content)
|
||||
const isChinese = /[\u4E00-\u9FFF]/.test(content)
|
||||
const isRussian = /[\u0400-\u04FF]/.test(content)
|
||||
const isArabic = /[\u0600-\u06FF]/.test(content)
|
||||
|
||||
// Détection du français par des mots et caractères caractéristiques
|
||||
const frenchWords = /\b(le|la|les|un|une|des|et|ou|mais|donc|pour|dans|sur|avec|sans|très|plus|moins|tout|tous|toute|toutes|ce|cette|ces|mon|ma|mes|ton|ta|tes|son|sa|ses|notre|nos|votre|vos|leur|leurs|je|tu|il|elle|nous|vous|ils|elles|est|sont|été|être|avoir|faire|aller|venir|voir|savoir|pouvoir|vouloir|falloir|comme|que|qui|dont|où|quand|pourquoi|comment|quel|quelle|quels|quelles)\b/i
|
||||
const frenchAccents = /[éèêàâôûùïüç]/i
|
||||
const isFrench = frenchWords.test(content) || frenchAccents.test(content)
|
||||
|
||||
// Déterminer la langue du prompt système
|
||||
let promptLanguage = 'en'
|
||||
let responseLanguage = 'English'
|
||||
|
||||
if (isPersian) {
|
||||
if (isFrench) {
|
||||
promptLanguage = 'fr' // Français
|
||||
responseLanguage = 'French'
|
||||
} else if (isPersian) {
|
||||
promptLanguage = 'fa' // Persan
|
||||
responseLanguage = 'Persian'
|
||||
} else if (isChinese) {
|
||||
|
||||
33
keep-notes/app/api/canvas/route.ts
Normal file
33
keep-notes/app/api/canvas/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const body = await req.json()
|
||||
const { id, name, data } = body
|
||||
|
||||
if (id) {
|
||||
const canvas = await prisma.canvas.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { name, data }
|
||||
})
|
||||
return NextResponse.json({ success: true, canvas })
|
||||
} else {
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name,
|
||||
data,
|
||||
userId: session.user.id
|
||||
}
|
||||
})
|
||||
return NextResponse.json({ success: true, canvas })
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
297
keep-notes/app/api/chat/route.ts
Normal file
297
keep-notes/app/api/chat/route.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import { streamText, UIMessage } from 'ai'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
/**
|
||||
* Extract text content from a UIMessage's parts array.
|
||||
*/
|
||||
function extractTextFromUIMessage(msg: { parts?: Array<{ type: string; text?: string }>; content?: string }): string {
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
||||
.map((p) => p.text!)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an array of UIMessages (from the client) to CoreMessage[] for streamText.
|
||||
*/
|
||||
function toCoreMessages(uiMessages: UIMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return uiMessages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: extractTextFromUIMessage(m),
|
||||
}))
|
||||
.filter((m) => m.content.length > 0)
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 1. Auth check
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
const userId = session.user.id
|
||||
|
||||
// 2. Parse request body — messages arrive as UIMessage[] from DefaultChatTransport
|
||||
const body = await req.json()
|
||||
const { messages: rawMessages, conversationId, notebookId, language } = body as {
|
||||
messages: UIMessage[]
|
||||
conversationId?: string
|
||||
notebookId?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
// Convert UIMessages to CoreMessages for streamText
|
||||
const incomingMessages = toCoreMessages(rawMessages)
|
||||
|
||||
// 3. Manage conversation (create or fetch)
|
||||
let conversation: { id: string; messages: Array<{ role: string; content: string }> }
|
||||
|
||||
if (conversationId) {
|
||||
const existing = await prisma.conversation.findUnique({
|
||||
where: { id: conversationId, userId },
|
||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})
|
||||
if (!existing) {
|
||||
return new Response('Conversation not found', { status: 404 })
|
||||
}
|
||||
conversation = existing
|
||||
} else {
|
||||
const userMessage = incomingMessages[incomingMessages.length - 1]?.content || 'New conversation'
|
||||
const created = await prisma.conversation.create({
|
||||
data: {
|
||||
userId,
|
||||
notebookId: notebookId || null,
|
||||
title: userMessage.substring(0, 50) + (userMessage.length > 50 ? '...' : ''),
|
||||
},
|
||||
include: { messages: true },
|
||||
})
|
||||
conversation = created
|
||||
}
|
||||
|
||||
// 4. RAG retrieval
|
||||
const currentMessage = incomingMessages[incomingMessages.length - 1]?.content || ''
|
||||
|
||||
// Load translations for the requested language
|
||||
const lang = (language || 'en') as SupportedLanguage
|
||||
const translations = await loadTranslations(lang)
|
||||
const untitledText = getTranslationValue(translations, 'notes.untitled') || 'Untitled'
|
||||
|
||||
// If a notebook is selected, fetch its recent notes directly as context
|
||||
// This ensures the AI always has access to the notebook content,
|
||||
// even for vague queries like "what's in this notebook?"
|
||||
let notebookContext = ''
|
||||
if (notebookId) {
|
||||
const notebookNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
select: { id: true, title: true, content: true, updatedAt: true },
|
||||
})
|
||||
if (notebookNotes.length > 0) {
|
||||
notebookContext = notebookNotes
|
||||
.map(n => `NOTE [${n.title || untitledText}] (updated ${n.updatedAt.toLocaleDateString()}):\n${(n.content || '').substring(0, 1500)}`)
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
// Also run semantic search for the specific query
|
||||
const searchResults = await semanticSearchService.search(currentMessage, {
|
||||
notebookId,
|
||||
limit: notebookId ? 10 : 5,
|
||||
threshold: notebookId ? 0.3 : 0.5,
|
||||
defaultTitle: untitledText,
|
||||
})
|
||||
|
||||
const searchNotes = searchResults
|
||||
.map((r) => `NOTE [${r.title || untitledText}]: ${r.content}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
// Combine: full notebook context + semantic search results (deduplicated)
|
||||
const contextNotes = [notebookContext, searchNotes].filter(Boolean).join('\n\n---\n\n')
|
||||
|
||||
// 5. System prompt synthesis with RAG context
|
||||
// Language-aware prompts to avoid forcing French responses
|
||||
// Note: lang is already declared above when loading translations
|
||||
const promptLang: Record<string, { contextWithNotes: string; contextNoNotes: string; system: string }> = {
|
||||
en: {
|
||||
contextWithNotes: `## User's notes\n\n${contextNotes}\n\nWhen using info from the notes above, cite the source note title in parentheses, e.g.: "Deployment is done via Docker (💻 Development Guide)". Don't copy word for word — rephrase. If the notes don't cover the topic, say so and supplement with your general knowledge.`,
|
||||
contextNoNotes: "No relevant notes found for this question. Answer with your general knowledge.",
|
||||
system: `You are the AI assistant of Memento. The user asks you questions about their projects, technical docs, and notes. You must respond in a structured and helpful way.
|
||||
|
||||
## Format rules
|
||||
- Use markdown freely: headings (##, ###), lists, code blocks, bold, tables — anything that makes the response readable.
|
||||
- Structure your response with sections for technical questions or complex topics.
|
||||
- For simple, short questions, a direct paragraph is enough.
|
||||
|
||||
## Tone rules
|
||||
- Natural tone, neither corporate nor too casual.
|
||||
- No unnecessary intro phrases ("Here's what I found", "Based on your notes"). Answer directly.
|
||||
- No upsell questions at the end ("Would you like me to...", "Do you want..."). If you have useful additional info, just give it.
|
||||
- If the user says "Momento" they mean Memento (this app).`,
|
||||
},
|
||||
fr: {
|
||||
contextWithNotes: `## Notes de l'utilisateur\n\n${contextNotes}\n\nQuand tu utilises une info venant des notes ci-dessus, cite le titre de la note source entre parenthèses, ex: "Le déploiement se fait via Docker (💻 Development Guide)". Ne recopie pas mot pour mot — reformule. Si les notes ne couvrent pas le sujet, dis-le et complète avec tes connaissances générales.`,
|
||||
contextNoNotes: "Aucune note pertinente trouvée pour cette question. Réponds avec tes connaissances générales.",
|
||||
system: `Tu es l'assistant IA de Memento. L'utilisateur te pose des questions sur ses projets, sa doc technique, ses notes. Tu dois répondre de façon structurée et utile.
|
||||
|
||||
## Règles de format
|
||||
- Utilise le markdown librement : titres (##, ###), listes, code blocks, gras, tables — tout ce qui rend la réponse lisible.
|
||||
- Structure ta réponse avec des sections quand c'est une question technique ou un sujet complexe.
|
||||
- Pour les questions simples et courtes, un paragraphe direct suffit.
|
||||
|
||||
## Règles de ton
|
||||
- Ton naturel, ni corporate ni trop familier.
|
||||
- Pas de phrase d'intro inutile ("Voici ce que j'ai trouvé", "Basé sur vos notes"). Réponds directement.
|
||||
- Pas de question upsell à la fin ("Souhaitez-vous que je...", "Acceptez-vous que..."). Si tu as une info complémentaire utile, donne-la.
|
||||
- Si l'utilisateur dit "Momento" il parle de Memento (cette application).`,
|
||||
},
|
||||
fa: {
|
||||
contextWithNotes: `## یادداشتهای کاربر\n\n${contextNotes}\n\nهنگام استفاده از اطلاعات یادداشتهای بالا، عنوان یادداشت منبع را در پرانتز ذکر کنید. کپی نکنید — بازنویسی کنید. اگر یادداشتها موضوع را پوشش نمیدهند، بگویید و با دانش عمومی خود تکمیل کنید.`,
|
||||
contextNoNotes: "هیچ یادداشت مرتبطی برای این سؤال یافت نشد. با دانش عمومی خود پاسخ دهید.",
|
||||
system: `شما دستیار هوش مصنوعی Memento هستید. کاربر از شما درباره پروژهها، مستندات فنی و یادداشتهایش سؤال میکند. باید به شکلی ساختاریافته و مفید پاسخ دهید.
|
||||
|
||||
## قوانین قالببندی
|
||||
- از مارکداون آزادانه استفاده کنید: عناوین (##, ###)، لیستها، بلوکهای کد، پررنگ، جداول.
|
||||
- برای سؤالات فنی یا موضوعات پیچیده، پاسخ خود را بخشبندی کنید.
|
||||
- برای سؤالات ساده و کوتاه، یک پاراگراف مستقیم کافی است.
|
||||
|
||||
## قوانین لحن
|
||||
- لحن طبیعی، نه رسمی بیش از حد و نه خیلی غیررسمی.
|
||||
- بدون جمله مقدمه اضافی. مستقیم پاسخ دهید.
|
||||
- بدون سؤال فروشی در انتها. اگر اطلاعات تکمیلی مفید دارید، مستقیم بدهید.
|
||||
- اگر کاربر "Momento" میگوید، منظورش Memento (این برنامه) است.`,
|
||||
},
|
||||
es: {
|
||||
contextWithNotes: `## Notas del usuario\n\n${contextNotes}\n\nCuando uses información de las notas anteriores, cita el título de la nota fuente entre paréntesis. No copies palabra por palabra — reformula. Si las notas no cubren el tema, dilo y complementa con tu conocimiento general.`,
|
||||
contextNoNotes: "No se encontraron notas relevantes para esta pregunta. Responde con tu conocimiento general.",
|
||||
system: `Eres el asistente de IA de Memento. El usuario te hace preguntas sobre sus proyectos, documentación técnica y notas. Debes responder de forma estructurada y útil.
|
||||
|
||||
## Reglas de formato
|
||||
- Usa markdown libremente: títulos (##, ###), listas, bloques de código, negritas, tablas.
|
||||
- Estructura tu respuesta con secciones para preguntas técnicas o temas complejos.
|
||||
- Para preguntas simples y cortas, un párrafo directo es suficiente.
|
||||
|
||||
## Reglas de tono
|
||||
- Tono natural, ni corporativo ni demasiado informal.
|
||||
- Sin frases de introducción innecesarias. Responde directamente.
|
||||
- Sin preguntas de venta al final. Si tienes información complementaria útil, dala directamente.`,
|
||||
},
|
||||
de: {
|
||||
contextWithNotes: `## Notizen des Benutzers\n\n${contextNotes}\n\nWenn du Infos aus den obigen Notizen verwendest, zitiere den Titel der Quellnotiz in Klammern. Nicht Wort für Wort kopieren — umformulieren. Wenn die Notizen das Thema nicht abdecken, sag es und ergänze mit deinem Allgemeinwissen.`,
|
||||
contextNoNotes: "Keine relevanten Notizen für diese Frage gefunden. Antworte mit deinem Allgemeinwissen.",
|
||||
system: `Du bist der KI-Assistent von Memento. Der Benutzer stellt dir Fragen zu seinen Projekten, technischen Dokumentationen und Notizen. Du musst strukturiert und hilfreich antworten.
|
||||
|
||||
## Formatregeln
|
||||
- Verwende Markdown frei: Überschriften (##, ###), Listen, Code-Blöcke, Fettdruck, Tabellen.
|
||||
- Strukturiere deine Antwort mit Abschnitten bei technischen Fragen oder komplexen Themen.
|
||||
- Bei einfachen, kurzen Fragen reicht ein direkter Absatz.
|
||||
|
||||
## Tonregeln
|
||||
- Natürlicher Ton, weder zu geschäftsmäßig noch zu umgangssprachlich.
|
||||
- Keine unnötigen Einleitungssätze. Antworte direkt.
|
||||
- Keine Upsell-Fragen am Ende. Gib nützliche Zusatzinfos einfach direkt.`,
|
||||
},
|
||||
it: {
|
||||
contextWithNotes: `## Note dell'utente\n\n${contextNotes}\n\nQuando usi informazioni dalle note sopra, cita il titolo della nota fonte tra parentesi. Non copiare parola per parola — riformula. Se le note non coprono l'argomento, dillo e integra con la tua conoscenza generale.`,
|
||||
contextNoNotes: "Nessuna nota rilevante trovata per questa domanda. Rispondi con la tua conoscenza generale.",
|
||||
system: `Sei l'assistente IA di Memento. L'utente ti fa domande sui suoi progetti, documentazione tecnica e note. Devi rispondere in modo strutturato e utile.
|
||||
|
||||
## Regole di formato
|
||||
- Usa markdown liberamente: titoli (##, ###), elenchi, blocchi di codice, grassetto, tabelle.
|
||||
- Struttura la risposta con sezioni per domande tecniche o argomenti complessi.
|
||||
- Per domande semplici e brevi, un paragrafo diretto basta.
|
||||
|
||||
## Regole di tono
|
||||
- Tono naturale, né aziendale né troppo informale.
|
||||
- Nessuna frase introduttiva non necessaria. Rispondi direttamente.
|
||||
- Nessuna domanda di upsell alla fine. Se hai informazioni aggiuntive utili, dalle direttamente.`,
|
||||
},
|
||||
}
|
||||
|
||||
// Fallback to English if language not supported
|
||||
const prompts = promptLang[lang] || promptLang.en
|
||||
const contextBlock = contextNotes.length > 0
|
||||
? prompts.contextWithNotes
|
||||
: prompts.contextNoNotes
|
||||
|
||||
const systemPrompt = `${prompts.system}
|
||||
|
||||
${contextBlock}
|
||||
|
||||
${lang === 'en' ? 'Respond in the user\'s language.' : lang === 'fr' ? 'Réponds dans la langue de l\'utilisateur.' : 'Respond in the user\'s language.'}`
|
||||
|
||||
// 6. Build message history from DB + current messages
|
||||
const dbHistory = conversation.messages.map((m: { role: string; content: string }) => ({
|
||||
role: m.role as 'user' | 'assistant' | 'system',
|
||||
content: m.content,
|
||||
}))
|
||||
|
||||
// Only add the current user message if it's not already in DB history
|
||||
const lastIncoming = incomingMessages[incomingMessages.length - 1]
|
||||
const currentDbMessage = dbHistory[dbHistory.length - 1]
|
||||
const isNewMessage =
|
||||
lastIncoming &&
|
||||
(!currentDbMessage ||
|
||||
currentDbMessage.role !== 'user' ||
|
||||
currentDbMessage.content !== lastIncoming.content)
|
||||
|
||||
const allMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> = isNewMessage
|
||||
? [...dbHistory, { role: lastIncoming.role, content: lastIncoming.content }]
|
||||
: dbHistory
|
||||
|
||||
// 7. Get chat provider model
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const model = provider.getModel()
|
||||
|
||||
// 8. Save user message to DB before streaming
|
||||
if (isNewMessage && lastIncoming) {
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
role: 'user',
|
||||
content: lastIncoming.content,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 9. Stream response
|
||||
const result = streamText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
messages: allMessages,
|
||||
async onFinish({ text }) {
|
||||
// Save assistant message to DB after streaming completes
|
||||
await prisma.chatMessage.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// 10. Return streaming response with conversation ID header
|
||||
return result.toUIMessageStreamResponse({
|
||||
headers: {
|
||||
'X-Conversation-Id': conversation.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
isReminderDone: false,
|
||||
isArchived: false, // Optional: exclude archived notes
|
||||
trashedAt: null, // Exclude trashed notes
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
29
keep-notes/app/api/debug/test-chat/route.ts
Normal file
29
keep-notes/app/api/debug/test-chat/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { chatService } from '@/lib/ai/services/chat.service';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
console.log("TEST ROUTE INCOMING BODY:", body);
|
||||
|
||||
// Simulate what the server action does
|
||||
const result = await chatService.chat(body.message, body.conversationId, body.notebookId);
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
} catch (err: any) {
|
||||
console.error("====== TEST ROUTE CHAT ERROR ======");
|
||||
console.error("NAME:", err.name);
|
||||
console.error("MSG:", err.message);
|
||||
if (err.cause) console.error("CAUSE:", JSON.stringify(err.cause, null, 2));
|
||||
if (err.data) console.error("DATA:", JSON.stringify(err.data, null, 2));
|
||||
if (err.stack) console.error("STACK:", err.stack);
|
||||
console.error("===================================");
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
name: err.name,
|
||||
cause: err.cause,
|
||||
data: err.data
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,20 @@ export async function GET(
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
const share = await prisma.noteShare.findUnique({
|
||||
where: {
|
||||
noteId_userId: {
|
||||
noteId: note.id,
|
||||
userId: session.user.id
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!share || share.status !== 'accepted') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -92,11 +102,29 @@ export async function PUT(
|
||||
if ('labels' in body) {
|
||||
updateData.labels = body.labels ?? null
|
||||
}
|
||||
updateData.updatedAt = new Date()
|
||||
|
||||
// Only update if data actually changed
|
||||
const hasChanges = Object.keys(updateData).some((key) => {
|
||||
const newValue = updateData[key]
|
||||
const oldValue = (existingNote as any)[key]
|
||||
// Handle arrays/objects by comparing JSON
|
||||
if (typeof newValue === 'object' && newValue !== null) {
|
||||
return JSON.stringify(newValue) !== JSON.stringify(oldValue)
|
||||
}
|
||||
return newValue !== oldValue
|
||||
})
|
||||
|
||||
// If no changes, return existing note without updating timestamp
|
||||
if (!hasChanges) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(existingNote),
|
||||
})
|
||||
}
|
||||
|
||||
const note = await prisma.note.update({
|
||||
where: { id },
|
||||
data: updateData
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -146,13 +174,14 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
await prisma.note.update({
|
||||
where: { id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note deleted successfully'
|
||||
message: 'Note moved to trash'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { deleteImageFileSafely, parseImageUrls } from '@/lib/image-cleanup'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -14,6 +15,12 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch notes with images before deleting for cleanup
|
||||
const notesWithImages = await prisma.note.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: { id: true, images: true },
|
||||
})
|
||||
|
||||
// Delete all notes for the user (cascade will handle labels-note relationships)
|
||||
const result = await prisma.note.deleteMany({
|
||||
where: {
|
||||
@@ -21,6 +28,13 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up image files from disk (best-effort, don't block response)
|
||||
const imageCleanup = Promise.allSettled(
|
||||
notesWithImages.flatMap(note =>
|
||||
parseImageUrls(note.images).map(url => deleteImageFileSafely(url, note.id))
|
||||
)
|
||||
)
|
||||
|
||||
// Delete all labels for the user
|
||||
await prisma.label.deleteMany({
|
||||
where: {
|
||||
@@ -39,6 +53,9 @@ export async function POST(req: NextRequest) {
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings/data')
|
||||
|
||||
// Await cleanup in background (don't block response)
|
||||
imageCleanup.catch(() => {})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedNotes: result.count
|
||||
|
||||
@@ -16,7 +16,8 @@ export async function GET(req: NextRequest) {
|
||||
// Fetch all notes with related data
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
},
|
||||
include: {
|
||||
labelRelations: {
|
||||
@@ -107,7 +108,7 @@ export async function GET(req: NextRequest) {
|
||||
return new NextResponse(jsonString, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="keep-notes-export-${new Date().toISOString().split('T')[0]}.json"`
|
||||
'Content-Disposition': `attachment; filename="memento-export-${new Date().toISOString().split('T')[0]}.json"`
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,7 +19,8 @@ export async function GET(request: NextRequest) {
|
||||
const search = searchParams.get('search')
|
||||
|
||||
let where: any = {
|
||||
userId: session.user.id
|
||||
userId: session.user.id,
|
||||
trashedAt: null
|
||||
}
|
||||
|
||||
if (!includeArchived) {
|
||||
@@ -210,13 +211,14 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
await prisma.note.update({
|
||||
where: { id },
|
||||
data: { trashedAt: new Date() }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Note deleted successfully'
|
||||
message: 'Note moved to trash'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
--color-background-dark: #1a1d23;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for better aesthetics - Keep style */
|
||||
/* Custom scrollbar for better aesthetics */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -489,4 +489,11 @@
|
||||
/* Ensure note cards work properly with Muuri */
|
||||
.muuri-item>* {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Force URLs/links to render LTR even in RTL mode */
|
||||
[dir="rtl"] .prose a {
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||
import { DirectionInitializer } from "@/components/direction-initializer";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -14,7 +15,7 @@ const inter = Inter({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Memento - Your Digital Notepad",
|
||||
description: "A beautiful note-taking app inspired by Google Keep, built with Next.js 16",
|
||||
description: "A beautiful note-taking app built with Next.js 16",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: "/icons/icon-512.svg",
|
||||
@@ -37,6 +38,23 @@ function getHtmlClass(theme?: string): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline script that runs BEFORE React hydrates.
|
||||
* Reads the user's saved language from localStorage and sets
|
||||
* `dir` on <html> immediately — prevents RTL/LTR flash.
|
||||
*/
|
||||
const directionScript = `
|
||||
(function(){
|
||||
try {
|
||||
var lang = localStorage.getItem('user-language');
|
||||
if (lang === 'fa' || lang === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -45,16 +63,17 @@ export default async function RootLayout({
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// Fetch user settings server-side with optimized single session check
|
||||
const [aiSettings, userSettings] = await Promise.all([
|
||||
getAISettings(userId),
|
||||
getUserSettings(userId)
|
||||
getUserSettings(userId),
|
||||
])
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning className={getHtmlClass(userSettings.theme)}>
|
||||
<head />
|
||||
<body className={inter.className}>
|
||||
<SessionProviderWrapper>
|
||||
<DirectionInitializer />
|
||||
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@@ -9,7 +9,7 @@ export function AdminContentArea({ children, className }: AdminContentAreaProps)
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'flex-1 bg-gray-50 dark:bg-zinc-950 p-6 overflow-auto',
|
||||
'flex-1 bg-gray-50 dark:bg-zinc-950 p-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
499
keep-notes/components/agents/agent-form.tsx
Normal file
499
keep-notes/components/agents/agent-form.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Form Component
|
||||
* Simplified form for creating and editing agents.
|
||||
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
|
||||
|
||||
/** Small "?" tooltip shown next to form labels */
|
||||
function FieldHelp({ tooltip }: { tooltip: string }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="inline-flex items-center ml-1 text-slate-300 hover:text-slate-500 transition-colors">
|
||||
<HelpCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs text-balance">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentFormProps {
|
||||
agent?: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
type?: string | null
|
||||
role: string
|
||||
sourceUrls?: string | null
|
||||
sourceNotebookId?: string | null
|
||||
targetNotebookId?: string | null
|
||||
frequency: string
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
} | null
|
||||
notebooks: { id: string; name: string; icon?: string | null }[]
|
||||
onSave: (data: FormData) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
// --- Tool presets per type ---
|
||||
const TOOL_PRESETS: Record<string, string[]> = {
|
||||
scraper: ['web_scrape', 'note_create', 'memory_search'],
|
||||
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
|
||||
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
|
||||
custom: ['memory_search'],
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps) {
|
||||
const { t } = useLanguage()
|
||||
const [name, setName] = useState(agent?.name || '')
|
||||
const [description, setDescription] = useState(agent?.description || '')
|
||||
const [type, setType] = useState<AgentType>((agent?.type as AgentType) || 'scraper')
|
||||
const [role, setRole] = useState(agent?.role || '')
|
||||
const [urls, setUrls] = useState<string[]>(() => {
|
||||
if (agent?.sourceUrls) {
|
||||
try { return JSON.parse(agent.sourceUrls) } catch { return [''] }
|
||||
}
|
||||
return ['']
|
||||
})
|
||||
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
|
||||
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
|
||||
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
|
||||
const [selectedTools, setSelectedTools] = useState<string[]>(() => {
|
||||
if (agent?.tools) {
|
||||
try {
|
||||
const parsed = JSON.parse(agent.tools)
|
||||
if (parsed.length > 0) return parsed
|
||||
} catch { /* fall through to presets */ }
|
||||
}
|
||||
// New agent or old agent with empty tools: use preset defaults
|
||||
const defaultType = (agent?.type as AgentType) || 'scraper'
|
||||
return TOOL_PRESETS[defaultType] || []
|
||||
})
|
||||
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
|
||||
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
|
||||
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => {
|
||||
// Auto-open advanced if editing an agent with custom tools or custom prompt
|
||||
if (agent?.tools) {
|
||||
try {
|
||||
const tools = JSON.parse(agent.tools)
|
||||
if (tools.length > 0) return true
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// Also open if agent has a custom role (instructions)
|
||||
if (agent?.role && agent.role.trim().length > 0) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// Tool definitions
|
||||
const availableTools = useMemo(() => [
|
||||
{ id: 'web_search', icon: Globe, labelKey: 'agents.tools.webSearch', external: true },
|
||||
{ id: 'web_scrape', icon: ExternalLink, labelKey: 'agents.tools.webScrape', external: true },
|
||||
{ id: 'note_search', icon: FileSearch, labelKey: 'agents.tools.noteSearch', external: false },
|
||||
{ id: 'note_read', icon: FileText, labelKey: 'agents.tools.noteRead', external: false },
|
||||
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
|
||||
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', external: false },
|
||||
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch', external: false },
|
||||
], [])
|
||||
|
||||
// Track previous type to detect user-initiated type changes
|
||||
const prevTypeRef = useRef(type)
|
||||
|
||||
// When user explicitly changes type (not on mount), reset tools to presets
|
||||
if (prevTypeRef.current !== type) {
|
||||
prevTypeRef.current = type
|
||||
// This is a user-initiated type change, not a mount
|
||||
// We queue the state update to happen after render
|
||||
setSelectedTools(TOOL_PRESETS[type] || [])
|
||||
setRole('')
|
||||
}
|
||||
|
||||
const addUrl = () => setUrls([...urls, ''])
|
||||
const removeUrl = (index: number) => setUrls(urls.filter((_, i) => i !== index))
|
||||
const updateUrl = (index: number, value: string) => {
|
||||
const newUrls = [...urls]
|
||||
newUrls[index] = value
|
||||
setUrls(newUrls)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
toast.error(t('agents.form.nameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set('name', name.trim())
|
||||
formData.set('description', description.trim())
|
||||
formData.set('type', type)
|
||||
formData.set('role', role || t(`agents.defaultRoles.${type}`))
|
||||
formData.set('frequency', frequency)
|
||||
formData.set('targetNotebookId', targetNotebookId)
|
||||
|
||||
if (type === 'monitor') {
|
||||
formData.set('sourceNotebookId', sourceNotebookId)
|
||||
}
|
||||
|
||||
const validUrls = urls.filter(u => u.trim())
|
||||
if (validUrls.length > 0) {
|
||||
formData.set('sourceUrls', JSON.stringify(validUrls))
|
||||
}
|
||||
|
||||
formData.set('tools', JSON.stringify(selectedTools))
|
||||
formData.set('maxSteps', String(maxSteps))
|
||||
formData.set('notifyEmail', String(notifyEmail))
|
||||
formData.set('includeImages', String(includeImages))
|
||||
|
||||
await onSave(formData)
|
||||
} catch {
|
||||
toast.error(t('agents.toasts.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showSourceNotebook = type === 'monitor'
|
||||
|
||||
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
|
||||
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
|
||||
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
|
||||
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
|
||||
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header — editable agent name */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="text-lg font-semibold text-slate-800 bg-transparent border-none outline-none focus:ring-0 p-0 flex-1 placeholder:text-slate-300"
|
||||
placeholder={t('agents.form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<button onClick={onCancel} className="p-1 rounded-md hover:bg-slate-100 ml-3">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
{/* Agent Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('agents.form.agentType')}<FieldHelp tooltip={t('agents.help.tooltips.agentType')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{agentTypes.map(at => (
|
||||
<button
|
||||
key={at.value}
|
||||
type="button"
|
||||
onClick={() => setType(at.value)}
|
||||
className={`
|
||||
text-left px-3 py-2.5 rounded-lg border-2 transition-all text-sm
|
||||
${type === at.value
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: 'border-slate-200 text-slate-600 hover:border-slate-300'}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium">{t(at.labelKey)}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{t(at.descKey)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Research Topic (researcher only) — replaces Description for this type */}
|
||||
{type === 'researcher' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.researchTopic')}<FieldHelp tooltip={t('agents.help.tooltips.researchTopic')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t('agents.form.researchTopicPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (for non-researcher types) */}
|
||||
{type !== 'researcher' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.description')}<FieldHelp tooltip={t('agents.help.tooltips.description')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t('agents.form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URLs (scraper and custom only — researcher uses search, not URLs) */}
|
||||
{(type === 'scraper' || type === 'custom') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('agents.form.urlsLabel')}<FieldHelp tooltip={t('agents.help.tooltips.urls')} />
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{urls.map((url, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={e => updateUrl(i, e.target.value)}
|
||||
className="flex-1 px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUrl(i)}
|
||||
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addUrl}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('agents.form.addUrl')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Notebook (monitor only) */}
|
||||
{showSourceNotebook && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||
<select
|
||||
value={sourceNotebookId}
|
||||
onChange={e => setSourceNotebookId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
>
|
||||
<option value="">{t('agents.form.selectNotebook')}</option>
|
||||
{notebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>
|
||||
{nb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Notebook */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
||||
<select
|
||||
value={targetNotebookId}
|
||||
onChange={e => setTargetNotebookId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
>
|
||||
<option value="">{t('agents.form.inbox')}</option>
|
||||
{notebooks.map(nb => (
|
||||
<option key={nb.id} value={nb.id}>
|
||||
{nb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
>
|
||||
<option value="manual">{t('agents.frequencies.manual')}</option>
|
||||
<option value="hourly">{t('agents.frequencies.hourly')}</option>
|
||||
<option value="daily">{t('agents.frequencies.daily')}</option>
|
||||
<option value="weekly">{t('agents.frequencies.weekly')}</option>
|
||||
<option value="monthly">{t('agents.frequencies.monthly')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Email Notification */}
|
||||
<div
|
||||
onClick={() => setNotifyEmail(!notifyEmail)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
notifyEmail
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Mail className={`w-4 h-4 flex-shrink-0 ${notifyEmail ? 'text-primary' : 'text-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700">{t('agents.form.notifyEmail')}</div>
|
||||
<div className="text-xs text-slate-400">{t('agents.form.notifyEmailHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-slate-200'}`}>
|
||||
<div className={`w-4 h-4 bg-white rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include Images */}
|
||||
<div
|
||||
onClick={() => setIncludeImages(!includeImages)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
includeImages
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className={`w-4 h-4 flex-shrink-0 ${includeImages ? 'text-primary' : 'text-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700">{t('agents.form.includeImages')}</div>
|
||||
<div className="text-xs text-slate-400">{t('agents.form.includeImagesHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-slate-200'}`}>
|
||||
<div className={`w-4 h-4 bg-white rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced mode toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700 font-medium w-full pt-2 border-t border-slate-100"
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{t('agents.form.advancedMode')}
|
||||
</button>
|
||||
|
||||
{/* Advanced: System Prompt */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{t('agents.form.instructions')}
|
||||
<FieldHelp tooltip={t('agents.help.tooltips.instructions')} />
|
||||
<span className="text-xs text-slate-400 font-normal ml-1">({t('agents.form.instructionsHint')})</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-y min-h-[80px]"
|
||||
placeholder={t('agents.form.instructionsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: Tools */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('agents.tools.title')}<FieldHelp tooltip={t('agents.help.tooltips.tools')} /></label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{availableTools.map(at => {
|
||||
const Icon = at.icon
|
||||
const isSelected = selectedTools.includes(at.id)
|
||||
return (
|
||||
<button
|
||||
key={at.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTools(prev =>
|
||||
isSelected ? prev.filter(t => t !== at.id) : [...prev, at.id]
|
||||
)
|
||||
}}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all text-left
|
||||
${isSelected
|
||||
? 'border-primary bg-primary/5 text-primary font-medium'
|
||||
: 'border-slate-200 text-slate-600 hover:border-slate-300'}
|
||||
`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t(at.labelKey)}</span>
|
||||
{at.external && !isSelected && (
|
||||
<span className="ml-auto text-[10px] text-amber-500 bg-amber-50 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedTools.length > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-1.5">
|
||||
{t('agents.tools.selected', { count: selectedTools.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced: Max Steps */}
|
||||
{selectedTools.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('agents.tools.maxSteps')}<FieldHelp tooltip={t('agents.help.tooltips.maxSteps')} />
|
||||
<span className="text-slate-400 font-normal ml-1">({maxSteps})</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={3}
|
||||
max={25}
|
||||
value={maxSteps}
|
||||
onChange={e => setMaxSteps(Number(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-slate-400">
|
||||
<span>3</span>
|
||||
<span>25</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{t('agents.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
keep-notes/components/agents/agent-help.tsx
Normal file
96
keep-notes/components/agents/agent-help.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Help Modal
|
||||
* Rich contextual help guide for the Agents page.
|
||||
* Collapsible sections with Markdown content inside each.
|
||||
*/
|
||||
|
||||
import { X, LifeBuoy } from 'lucide-react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AgentHelpProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SECTIONS = [
|
||||
{ key: 'whatIsAgent', defaultOpen: true },
|
||||
{ key: 'howToUse', defaultOpen: false },
|
||||
{ key: 'types', defaultOpen: false },
|
||||
{ key: 'advanced', defaultOpen: false },
|
||||
{ key: 'tools', defaultOpen: false },
|
||||
{ key: 'frequency', defaultOpen: false },
|
||||
{ key: 'targetNotebook', defaultOpen: false },
|
||||
{ key: 'templates', defaultOpen: false },
|
||||
{ key: 'tips', defaultOpen: false },
|
||||
] as const
|
||||
|
||||
export function AgentHelp({ onClose }: AgentHelpProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LifeBuoy className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t('agents.help.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content — collapsible sections */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-2">
|
||||
{SECTIONS.map(section => (
|
||||
<details
|
||||
key={section.key}
|
||||
open={section.defaultOpen}
|
||||
className="group border-b border-slate-100 dark:border-slate-800 last:border-b-0"
|
||||
>
|
||||
<summary className="flex items-center gap-2 cursor-pointer py-3 font-medium text-slate-800 dark:text-slate-200 select-none hover:text-primary transition-colors text-sm">
|
||||
<span className="text-primary text-xs transition-transform group-open:rotate-90">▸</span>
|
||||
{t(`agents.help.${section.key}`)}
|
||||
</summary>
|
||||
<div className="pb-4 pl-5 prose prose-slate dark:prose-invert prose-sm max-w-none
|
||||
prose-headings:font-semibold prose-headings:text-slate-800 dark:prose-headings:text-slate-200
|
||||
prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1
|
||||
prose-p:leading-relaxed prose-p:text-slate-600 dark:prose-p:text-slate-400 prose-p:my-1.5
|
||||
prose-li:text-slate-600 dark:prose-li:text-slate-400 prose-li:my-0.5
|
||||
prose-strong:text-slate-700 dark:prose-strong:text-slate-300
|
||||
prose-code:text-primary prose-code:bg-primary/5 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
|
||||
prose-ul:my-2 prose-ol:my-2
|
||||
prose-hr:border-slate-200 dark:prose-hr:border-slate-700
|
||||
prose-table:text-xs
|
||||
prose-th:text-left prose-th:font-medium prose-th:text-slate-700 dark:prose-th:text-slate-300 prose-th:py-1 prose-th:pr-3
|
||||
prose-td:text-slate-600 dark:prose-td:text-slate-400 prose-td:py-1 prose-td:pr-3
|
||||
prose-blockquote:border-primary/30 prose-blockquote:text-slate-500 dark:prose-blockquote:text-slate-400
|
||||
">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{t(`agents.help.${section.key}Content`)}
|
||||
</Markdown>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{t('agents.help.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
keep-notes/components/agents/agent-run-log.tsx
Normal file
170
keep-notes/components/agents/agent-run-log.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agent Run Log
|
||||
* Shows execution history for an agent.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, CheckCircle2, XCircle, Loader2, Clock, ChevronDown, Wrench } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface AgentRunLogProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface Action {
|
||||
id: string
|
||||
status: string
|
||||
result?: string | null
|
||||
log?: string | null
|
||||
input?: string | null
|
||||
toolLog?: string | null
|
||||
tokensUsed?: number | null
|
||||
createdAt: string | Date
|
||||
}
|
||||
|
||||
interface ToolLogStep {
|
||||
step: number
|
||||
text?: string
|
||||
toolCalls?: Array<{ toolName: string; args: any }>
|
||||
toolResults?: Array<{ toolName: string; preview?: string }>
|
||||
}
|
||||
|
||||
const statusKeys: Record<string, string> = {
|
||||
success: 'agents.status.success',
|
||||
failure: 'agents.status.failure',
|
||||
running: 'agents.status.running',
|
||||
pending: 'agents.status.pending',
|
||||
}
|
||||
|
||||
export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [actions, setActions] = useState<Action[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const { getAgentActions } = await import('@/app/actions/agent-actions')
|
||||
const data = await getAgentActions(agentId)
|
||||
setActions(data)
|
||||
} catch {
|
||||
// Silent fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [agentId])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full max-h-[70vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800">{t('agents.runLog.title')}</h3>
|
||||
<p className="text-xs text-slate-400">{agentName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-md hover:bg-slate-100">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && actions.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-400 py-8">
|
||||
{t('agents.runLog.noHistory')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actions.map(action => {
|
||||
let toolSteps: ToolLogStep[] = []
|
||||
try {
|
||||
toolSteps = action.toolLog ? JSON.parse(action.toolLog) : []
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className={`
|
||||
p-3 rounded-lg border
|
||||
${action.status === 'success' ? 'bg-green-50/50 border-green-100' : ''}
|
||||
${action.status === 'failure' ? 'bg-red-50/50 border-red-100' : ''}
|
||||
${action.status === 'running' ? 'bg-blue-50/50 border-blue-100' : ''}
|
||||
${action.status === 'pending' ? 'bg-slate-50 border-slate-100' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
|
||||
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-500" />}
|
||||
{action.status === 'running' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
|
||||
{action.status === 'pending' && <Clock className="w-4 h-4 text-slate-400" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t(statusKeys[action.status] || action.status)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
{action.log && (
|
||||
<p className="text-xs text-slate-500 mt-1 line-clamp-2">{action.log}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool trace */}
|
||||
{toolSteps.length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="flex items-center gap-1.5 text-xs text-primary cursor-pointer hover:text-primary/80 font-medium">
|
||||
<Wrench className="w-3 h-3" />
|
||||
{t('agents.runLog.toolTrace', { count: toolSteps.length })}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 pl-2">
|
||||
{toolSteps.map((step, i) => (
|
||||
<div key={i} className="text-xs border-l-2 border-primary/30 pl-2 py-1">
|
||||
<span className="font-medium text-slate-600">{t('agents.runLog.step', { num: step.step })}</span>
|
||||
{step.toolCalls && step.toolCalls.length > 0 && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{step.toolCalls.map((tc, j) => (
|
||||
<div key={j} className="bg-slate-100 rounded px-2 py-1">
|
||||
<span className="font-mono text-primary">{tc.toolName}</span>
|
||||
<span className="text-slate-400 ml-1">
|
||||
{JSON.stringify(tc.args).substring(0, 80)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from './ui/dialog'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { Wand2, Loader2, ChevronRight, CheckCircle2 } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { OrganizationPlan, NotebookOrganization } from '@/lib/ai/services'
|
||||
@@ -164,7 +165,7 @@ export function BatchOrganizationDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="!max-w-5xl max-h-[85vh] overflow-y-auto !w-[95vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="h-5 w-5" />
|
||||
@@ -238,7 +239,10 @@ export function BatchOrganizationDialog({
|
||||
aria-label={t('ai.batchOrganization.selectAllIn', { notebook: notebook.notebookName })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{notebook.notebookIcon}</span>
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(notebook.notebookIcon)
|
||||
return <Icon className="h-5 w-5" />
|
||||
})()}
|
||||
<span className="font-semibold">
|
||||
{notebook.notebookName}
|
||||
</span>
|
||||
|
||||
189
keep-notes/components/chat/chat-container.tsx
Normal file
189
keep-notes/components/chat/chat-container.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { ChatSidebar } from './chat-sidebar'
|
||||
import { ChatMessages } from './chat-messages'
|
||||
import { ChatInput } from './chat-input'
|
||||
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
|
||||
import { toast } from 'sonner'
|
||||
import type { UIMessage } from 'ai'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatContainerProps {
|
||||
initialConversations: any[]
|
||||
notebooks: any[]
|
||||
}
|
||||
|
||||
export function ChatContainer({ initialConversations, notebooks }: ChatContainerProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [conversations, setConversations] = useState(initialConversations)
|
||||
const [currentId, setCurrentId] = useState<string | null>(null)
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
|
||||
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
|
||||
// Prevents the useEffect from loading an empty conversation
|
||||
// when we just created one via createConversation()
|
||||
const skipHistoryLoad = useRef(false)
|
||||
|
||||
const transport = useRef(new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
})).current
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
setMessages,
|
||||
} = useChat({
|
||||
transport,
|
||||
onError: (error) => {
|
||||
toast.error(error.message || t('chat.assistantError'))
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
|
||||
// Sync historyMessages after each completed streaming response
|
||||
// so the display doesn't revert to stale history
|
||||
useEffect(() => {
|
||||
if (status === 'ready' && messages.length > 0) {
|
||||
setHistoryMessages([...messages])
|
||||
}
|
||||
}, [status, messages])
|
||||
|
||||
// Load conversation details when the user selects a different conversation
|
||||
useEffect(() => {
|
||||
// Skip if we just created the conversation — useChat already has the messages
|
||||
if (skipHistoryLoad.current) {
|
||||
skipHistoryLoad.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (currentId) {
|
||||
const loadMessages = async () => {
|
||||
setIsLoadingHistory(true)
|
||||
try {
|
||||
const details = await getConversationDetails(currentId)
|
||||
if (details) {
|
||||
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
|
||||
id: m.id || `hist-${i}`,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
parts: [{ type: 'text' as const, text: m.content }],
|
||||
}))
|
||||
setHistoryMessages(loaded)
|
||||
setMessages(loaded)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('chat.loadError'))
|
||||
} finally {
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
}
|
||||
loadMessages()
|
||||
} else {
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentId])
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const updated = await getConversations()
|
||||
setConversations(updated)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
const handleSendMessage = async (content: string, notebookId?: string) => {
|
||||
if (notebookId) {
|
||||
setSelectedNotebook(notebookId)
|
||||
}
|
||||
|
||||
// If no active conversation, create one BEFORE streaming
|
||||
let convId = currentId
|
||||
if (!convId) {
|
||||
try {
|
||||
const result = await createConversation(content, notebookId || selectedNotebook)
|
||||
convId = result.id
|
||||
// Tell the useEffect to skip — we don't want to load an empty conversation
|
||||
skipHistoryLoad.current = true
|
||||
setCurrentId(convId)
|
||||
setHistoryMessages([])
|
||||
setConversations((prev) => [
|
||||
{ id: result.id, title: result.title, updatedAt: new Date() },
|
||||
...prev,
|
||||
])
|
||||
} catch {
|
||||
toast.error(t('chat.createError'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await sendMessage(
|
||||
{ text: content },
|
||||
{
|
||||
body: {
|
||||
conversationId: convId,
|
||||
notebookId: notebookId || selectedNotebook || undefined,
|
||||
language,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleNewChat = () => {
|
||||
setCurrentId(null)
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
setSelectedNotebook(undefined)
|
||||
}
|
||||
|
||||
const handleDeleteConversation = async (id: string) => {
|
||||
try {
|
||||
await deleteConversation(id)
|
||||
if (currentId === id) {
|
||||
handleNewChat()
|
||||
}
|
||||
await refreshConversations()
|
||||
} catch {
|
||||
toast.error(t('chat.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
// During streaming or if useChat has more messages than history, prefer useChat
|
||||
const displayMessages = isLoading || messages.length > historyMessages.length
|
||||
? messages
|
||||
: historyMessages
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]">
|
||||
<ChatSidebar
|
||||
conversations={conversations}
|
||||
currentId={currentId}
|
||||
onSelect={setCurrentId}
|
||||
onNew={handleNewChat}
|
||||
onDelete={handleDeleteConversation}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col h-full overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
|
||||
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-white dark:from-[#1a1c22] via-white/90 dark:via-[#1a1c22]/90 to-transparent pt-6 pb-4">
|
||||
<div className="w-full max-w-4xl px-4">
|
||||
<ChatInput
|
||||
onSend={handleSendMessage}
|
||||
isLoading={isLoading}
|
||||
notebooks={notebooks}
|
||||
currentNotebookId={selectedNotebook || null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
keep-notes/components/chat/chat-input.tsx
Normal file
129
keep-notes/components/chat/chat-input.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, BookOpen, X } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, notebookId?: string) => void
|
||||
isLoading?: boolean
|
||||
notebooks: any[]
|
||||
currentNotebookId?: string | null
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId }: ChatInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentNotebookId) {
|
||||
setSelectedNotebook(currentNotebookId)
|
||||
}
|
||||
}, [currentNotebookId])
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isLoading) return
|
||||
onSend(input, selectedNotebook)
|
||||
setInput('')
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<div className="w-full relative">
|
||||
<div className="relative flex flex-col bg-slate-50 dark:bg-[#202228] rounded-[24px] border border-slate-200/60 dark:border-white/10 shadow-sm focus-within:shadow-md focus-within:border-slate-300 dark:focus-within:border-white/20 transition-all duration-300 overflow-hidden">
|
||||
|
||||
{/* Input Area */}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
placeholder={t('chat.placeholder')}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
{/* Bottom Actions Bar */}
|
||||
<div className="flex items-center justify-between px-3 pb-3 pt-1">
|
||||
{/* Context Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={selectedNotebook || 'global'}
|
||||
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-slate-50 dark:hover:bg-[#252830] transition-colors">
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<SelectValue placeholder={t('chat.allNotebooks')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
|
||||
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
|
||||
{notebooks.map((nb) => (
|
||||
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(nb.icon)
|
||||
return <Icon className="w-3.5 h-3.5" />
|
||||
})()} {nb.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{selectedNotebook && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
|
||||
{t('chat.active')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
disabled={!input.trim() || isLoading}
|
||||
onClick={handleSend}
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full h-8 w-8 transition-all duration-200",
|
||||
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-3">
|
||||
<span className="text-[11px] text-muted-foreground/60 w-full block">
|
||||
{t('chat.disclaimer')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
keep-notes/components/chat/chat-messages.tsx
Normal file
84
keep-notes/components/chat/chat-messages.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { User, Bot, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: any[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function getMessageContent(msg: any): string {
|
||||
if (typeof msg.content === 'string') return msg.content
|
||||
if (msg.parts && Array.isArray(msg.parts)) {
|
||||
return msg.parts
|
||||
.filter((p: any) => p.type === 'text')
|
||||
.map((p: any) => p.text)
|
||||
.join('')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
|
||||
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
|
||||
<Bot className="h-12 w-12 text-primary opacity-60" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
|
||||
{t('chat.welcome')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => {
|
||||
const content = getMessageContent(msg)
|
||||
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id || index}
|
||||
className={cn(
|
||||
"flex w-full px-4 md:px-0 py-6 my-2 group",
|
||||
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
|
||||
)}
|
||||
>
|
||||
{msg.role === 'user' ? (
|
||||
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-[#f4f4f5] dark:bg-[#2a2d36] text-slate-800 dark:text-slate-100 rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-slate-200/50 dark:border-white/5">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
|
||||
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
|
||||
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
|
||||
</Avatar>
|
||||
<div dir="auto" className="flex-1 overflow-hidden pt-1">
|
||||
{content ? (
|
||||
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : isLastAssistant ? (
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
keep-notes/components/chat/chat-sidebar.tsx
Normal file
127
keep-notes/components/chat/chat-sidebar.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface ChatSidebarProps {
|
||||
conversations: any[]
|
||||
currentId?: string | null
|
||||
onSelect: (id: string) => void
|
||||
onNew: () => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
conversations,
|
||||
currentId,
|
||||
onSelect,
|
||||
onNew,
|
||||
onDelete,
|
||||
}: ChatSidebarProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const dateLocale = language === 'fr' ? fr : enUS
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
|
||||
|
||||
const confirmDelete = (id: string) => {
|
||||
setPendingDelete(id)
|
||||
}
|
||||
|
||||
const cancelDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
}
|
||||
|
||||
const executeDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
setPendingDelete(null)
|
||||
if (onDelete) {
|
||||
await onDelete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r flex flex-col h-full bg-white dark:bg-[#1e2128]">
|
||||
<div className="p-4 border-bottom">
|
||||
<Button
|
||||
onClick={onNew}
|
||||
className="w-full justify-start gap-2 shadow-sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('chat.newConversation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
{t('chat.noHistory')}
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg transition-all group",
|
||||
currentId === chat.id
|
||||
? "bg-primary/10 text-primary dark:bg-primary/20"
|
||||
: "hover:bg-muted/50 text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="p-3 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate text-sm font-medium pr-6">
|
||||
{chat.title || t('chat.untitled')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-60 ml-6">
|
||||
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Delete button — visible on hover or when confirming */}
|
||||
{pendingDelete !== chat.id && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
|
||||
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Inline confirmation banner */}
|
||||
{pendingDelete === chat.id && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
|
||||
<button
|
||||
onClick={(e) => executeDelete(e, chat.id)}
|
||||
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
{t('chat.yes')}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDelete}
|
||||
className="p-0.5 hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 transition-all duration-150 ease-out',
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800 transition-all duration-150 ease-out',
|
||||
isHovered && 'scale-105',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Sparkles, X, Search, ArrowRight, Eye } from 'lucide-react'
|
||||
import { Sparkles, X, Search, ArrowRight, Eye, GitMerge } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ConnectionsOverlayProps {
|
||||
noteId: string
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onCompareNotes?: (noteIds: string[]) => void
|
||||
onMergeNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
export function ConnectionsOverlay({
|
||||
@@ -42,7 +43,8 @@ export function ConnectionsOverlay({
|
||||
onClose,
|
||||
noteId,
|
||||
onOpenNote,
|
||||
onCompareNotes
|
||||
onCompareNotes,
|
||||
onMergeNotes
|
||||
}: ConnectionsOverlayProps) {
|
||||
const { t } = useLanguage()
|
||||
const [connections, setConnections] = useState<ConnectionData[]>([])
|
||||
@@ -256,6 +258,21 @@ export function ConnectionsOverlay({
|
||||
{t('memoryEcho.editorSection.compare')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onMergeNotes && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onMergeNotes([noteId, conn.noteId])
|
||||
onClose()
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<GitMerge className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.editorSection.merge')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -295,19 +312,35 @@ export function ConnectionsOverlay({
|
||||
|
||||
{/* Footer - Action */}
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700">
|
||||
<Button
|
||||
className="w-full bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={() => {
|
||||
if (onCompareNotes && connections.length > 0) {
|
||||
const noteIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
|
||||
onCompareNotes([noteId, ...noteIds])
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
disabled={connections.length === 0}
|
||||
>
|
||||
{t('memoryEcho.overlay.viewAll')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
|
||||
onClick={() => {
|
||||
if (onCompareNotes && connections.length > 0) {
|
||||
const noteIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
|
||||
onCompareNotes([noteId, ...noteIds])
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
disabled={connections.length === 0}
|
||||
>
|
||||
{t('memoryEcho.overlay.viewAll')}
|
||||
</Button>
|
||||
|
||||
{onMergeNotes && connections.length > 0 && (
|
||||
<Button
|
||||
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
|
||||
onClick={() => {
|
||||
const allIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
|
||||
onMergeNotes([noteId, ...allIds])
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<GitMerge className="h-4 w-4 mr-2" />
|
||||
{t('memoryEcho.editorSection.mergeAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
23
keep-notes/components/direction-initializer.tsx
Normal file
23
keep-notes/components/direction-initializer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Sets document direction (RTL/LTR) on mount based on saved language.
|
||||
* Runs before paint to prevent visual flash.
|
||||
*/
|
||||
export function DirectionInitializer() {
|
||||
useEffect(() => {
|
||||
try {
|
||||
const lang = localStorage.getItem('user-language')
|
||||
if (lang === 'fa' || lang === 'ar') {
|
||||
document.documentElement.dir = 'rtl'
|
||||
document.documentElement.lang = lang
|
||||
} else {
|
||||
document.documentElement.dir = 'ltr'
|
||||
}
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -5,16 +5,19 @@ import { Note } from '@/lib/types'
|
||||
import { NoteCard } from './note-card'
|
||||
import { ChevronDown, ChevronUp, Pin } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode'
|
||||
|
||||
interface FavoritesSectionProps {
|
||||
pinnedNotes: Note[]
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function FavoritesSection({ pinnedNotes, onEdit, isLoading }: FavoritesSectionProps) {
|
||||
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const cardSizeMode = useCardSizeMode()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -68,12 +71,16 @@ export function FavoritesSection({ pinnedNotes, onEdit, isLoading }: FavoritesSe
|
||||
|
||||
{/* Collapsible Content */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
className={`favorites-grid ${cardSizeMode === 'uniform' ? 'favorites-columns' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'}`}
|
||||
data-card-size-mode={cardSizeMode}
|
||||
>
|
||||
{pinnedNotes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
onEdit={onEdit}
|
||||
onSizeChange={(size) => onSizeChange?.(note.id, size)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, Settings, LogOut, User, Shield, Coffee } from 'lucide-react'
|
||||
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, Settings, LogOut, User, Shield, Coffee, MessageSquare, FlaskConical, Bot } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -56,6 +56,8 @@ export function Header({
|
||||
const { t } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
|
||||
const noSidebarMode = ['/agents', '/chat', '/lab'].some(r => pathname.startsWith(r))
|
||||
|
||||
// Track last pushed search to avoid infinite loops
|
||||
const lastPushedSearch = useRef<string | null>(null)
|
||||
|
||||
@@ -327,7 +329,64 @@ export function Header({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 justify-end gap-4 items-center">
|
||||
<div className="flex flex-1 justify-end gap-2 items-center">
|
||||
|
||||
{/* Quick nav: Notes (hidden-sidebar only), Chat, Agents, Lab */}
|
||||
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
|
||||
{noSidebarMode && (
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
pathname === '/'
|
||||
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<StickyNote className="h-3.5 w-3.5" />
|
||||
<span>{t('sidebar.notes') || 'Notes'}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/chat"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
pathname === '/chat'
|
||||
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
<span>{t('nav.chat')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/agents"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
pathname === '/agents'
|
||||
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
<span>{t('nav.agents')}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/lab"
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
|
||||
pathname === '/lab'
|
||||
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<FlaskConical className="h-3.5 w-3.5" />
|
||||
<span>{t('nav.lab')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationPanel />
|
||||
|
||||
{/* Settings Button */}
|
||||
<Link
|
||||
|
||||
@@ -13,13 +13,13 @@ import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
import { FavoritesSection } from '@/components/favorites-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Wand2 } from 'lucide-react'
|
||||
import { Wand2, ChevronRight, Plus, FileText } from 'lucide-react'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LabelFilter } from '@/components/label-filter'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -45,7 +45,6 @@ type InitialSettings = {
|
||||
}
|
||||
|
||||
interface HomeClientProps {
|
||||
/** Notes pré-chargées côté serveur — hydratées immédiatement sans loading spinner */
|
||||
initialNotes: Note[]
|
||||
initialSettings: InitialSettings
|
||||
}
|
||||
@@ -132,6 +131,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
if (note) setEditingNote({ note, readOnly: false })
|
||||
}
|
||||
|
||||
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
|
||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
|
||||
}, [])
|
||||
|
||||
useReminderCheck(notes)
|
||||
|
||||
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
|
||||
@@ -154,10 +158,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
: await getAllNotes()
|
||||
|
||||
// Filtre notebook côté client
|
||||
// Shared notes appear ONLY in inbox (general notes), not in notebooks
|
||||
if (notebook) {
|
||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook)
|
||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
|
||||
} else {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
||||
}
|
||||
|
||||
// Filtre labels
|
||||
@@ -177,7 +182,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
)
|
||||
}
|
||||
|
||||
setNotes(allNotes)
|
||||
// Merger avec les tailles locales pour ne pas écraser les modifications
|
||||
setNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -191,11 +200,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
// Données initiales : filtrage inbox/notebook côté client seulement
|
||||
let filtered = initialNotes
|
||||
if (notebook) {
|
||||
filtered = initialNotes.filter(n => n.notebookId === notebook)
|
||||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||||
} else {
|
||||
filtered = initialNotes.filter(n => !n.notebookId)
|
||||
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
|
||||
}
|
||||
setNotes(filtered)
|
||||
// Merger avec les tailles déjà modifiées localement
|
||||
setNotes(prev => {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
setPinnedNotes(filtered.filter(n => n.isPinned))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -203,38 +216,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
|
||||
const { notebooks } = useNotebooks()
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
const [showNoteInput, setShowNoteInput] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setControls({
|
||||
isTabsMode: notesViewMode === 'tabs',
|
||||
openNoteComposer: () => setShowNoteInput(true),
|
||||
openNoteComposer: () => {},
|
||||
})
|
||||
return () => setControls(null)
|
||||
}, [notesViewMode, setControls])
|
||||
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
return ICON_MAP[iconName] || Folder
|
||||
}
|
||||
|
||||
const handleNoteCreatedWrapper = (note: any) => {
|
||||
handleNoteCreated(note)
|
||||
setShowNoteInput(false)
|
||||
}
|
||||
|
||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||
@@ -290,15 +282,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.addNote') || 'Add Note'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,21 +323,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.newNote')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoteInput && (
|
||||
{!isTabs && (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-in fade-in slide-in-from-top-4 duration-300',
|
||||
@@ -363,7 +338,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
>
|
||||
<NoteInput
|
||||
onNoteCreated={handleNoteCreatedWrapper}
|
||||
forceExpanded={true}
|
||||
fullWidth={isTabs}
|
||||
/>
|
||||
</div>
|
||||
@@ -376,6 +350,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<FavoritesSection
|
||||
pinnedNotes={pinnedNotes}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
/>
|
||||
|
||||
{notes.filter((note) => !note.isPinned).length > 0 && (
|
||||
@@ -384,6 +359,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
viewMode={notesViewMode}
|
||||
notes={notes.filter((note) => !note.isPinned)}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
currentNotebookId={searchParams.get('notebook')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,8 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const filesRef = useRef<BinaryFiles>({})
|
||||
|
||||
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
|
||||
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
|
||||
if (initialData) {
|
||||
@@ -32,6 +33,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
if (parsed && Array.isArray(parsed)) {
|
||||
return parsed
|
||||
} else if (parsed && parsed.elements) {
|
||||
// Restore binary files if present
|
||||
if (parsed.files && typeof parsed.files === 'object') {
|
||||
filesRef.current = parsed.files
|
||||
}
|
||||
return parsed.elements
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -57,34 +62,21 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Prevent Excalidraw from overriding document.documentElement.dir.
|
||||
// Excalidraw internally sets `document.documentElement.dir = "ltr"` which
|
||||
// breaks the RTL layout of the parent sidebar and header.
|
||||
useEffect(() => {
|
||||
const savedDir = document.documentElement.dir || 'ltr'
|
||||
|
||||
const dirObserver = new MutationObserver(() => {
|
||||
if (document.documentElement.dir !== savedDir) {
|
||||
document.documentElement.dir = savedDir
|
||||
}
|
||||
})
|
||||
dirObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] })
|
||||
|
||||
return () => dirObserver.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleChange = (
|
||||
excalidrawElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles
|
||||
) => {
|
||||
// Keep files ref up to date so we can include them in the save payload
|
||||
if (files) filesRef.current = files
|
||||
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
|
||||
|
||||
setSaveStatus('saving')
|
||||
saveTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
// Excalidraw states are purely based on the geometric elements
|
||||
const snapshot = JSON.stringify(excalidrawElements)
|
||||
// Save both elements and binary files so images persist across page changes
|
||||
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
|
||||
await fetch('/api/canvas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -100,8 +92,8 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
||||
<Excalidraw
|
||||
initialData={{ elements }}
|
||||
<Excalidraw
|
||||
initialData={{ elements, files: filesRef.current }}
|
||||
theme={isDarkMode ? "dark" : "light"}
|
||||
onChange={handleChange}
|
||||
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
|
||||
|
||||
63
keep-notes/components/lab/canvas-error-boundary.tsx
Normal file
63
keep-notes/components/lab/canvas-error-boundary.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AlertCircle, RefreshCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class CanvasErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('[CanvasErrorBoundary] caught error:', error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 bg-destructive/5 rounded-3xl border border-destructive/20 m-6 gap-4">
|
||||
<div className="p-4 bg-destructive/10 rounded-full">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xl font-bold">Oups ! Le Lab a rencontré un problème.</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Une erreur inattendue est survenue lors du chargement de l'espace de dessin.
|
||||
Cela peut arriver à cause d'un conflit de données ou d'une extension de navigateur.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Recharger la page
|
||||
</Button>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<pre className="mt-4 p-4 bg-black/5 rounded-lg text-xs font-mono overflow-auto max-w-full italic text-muted-foreground">
|
||||
{this.state.error?.message}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
27
keep-notes/components/lab/canvas-wrapper.tsx
Normal file
27
keep-notes/components/lab/canvas-wrapper.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { LabSkeleton } from './lab-skeleton'
|
||||
import { CanvasErrorBoundary } from './canvas-error-boundary'
|
||||
|
||||
const CanvasBoard = dynamic(
|
||||
() => import('./canvas-board').then((mod) => mod.CanvasBoard),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <LabSkeleton />
|
||||
}
|
||||
)
|
||||
|
||||
interface CanvasWrapperProps {
|
||||
canvasId?: string
|
||||
name: string
|
||||
initialData?: string
|
||||
}
|
||||
|
||||
export function CanvasWrapper(props: CanvasWrapperProps) {
|
||||
return (
|
||||
<CanvasErrorBoundary>
|
||||
<CanvasBoard {...props} />
|
||||
</CanvasErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { FlaskConical, Plus, X, ChevronDown, Trash2, Layout, MoreVertical } from 'lucide-react'
|
||||
import { FlaskConical, Plus, ChevronDown, Trash2, Layout } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { renameCanvas, deleteCanvas, createCanvas } from '@/app/actions/canvas-actions'
|
||||
import { useRouter } from 'next/navigation'
|
||||
@@ -25,7 +25,7 @@ interface LabHeaderProps {
|
||||
|
||||
export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHeaderProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
@@ -36,22 +36,21 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameCanvas(id, newName)
|
||||
toast.success(t('labHeader.renamed'))
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.renameError'))
|
||||
}
|
||||
setIsEditing(false)
|
||||
})
|
||||
|
||||
try {
|
||||
await renameCanvas(id, newName)
|
||||
toast.success(t('labHeader.renamed'))
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.renameError'))
|
||||
}
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const newCanvas = await createCanvas()
|
||||
const newCanvas = await createCanvas(language)
|
||||
router.push(`/lab?id=${newCanvas.id}`)
|
||||
toast.success(t('labHeader.created'))
|
||||
} catch (e) {
|
||||
@@ -148,49 +147,44 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Inline Rename */}
|
||||
<div className="ms-2 flex items-center gap-2 group">
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="bg-muted px-3 py-1.5 rounded-lg text-sm font-medium focus:ring-2 focus:ring-primary/20 outline-none w-[200px]"
|
||||
defaultValue={currentCanvas?.name}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(currentCanvas?.id!, e.currentTarget.value)
|
||||
if (e.key === 'Escape') setIsEditing(false)
|
||||
}}
|
||||
onBlur={(e) => handleRename(currentCanvas?.id!, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Inline Rename — click on project name to edit */}
|
||||
{currentCanvas && (
|
||||
<div className="ms-2 flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="bg-muted px-3 py-1.5 rounded-lg text-sm font-medium focus:ring-2 focus:ring-primary/20 outline-none w-[200px]"
|
||||
defaultValue={currentCanvas.name}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(currentCanvas.id, e.currentTarget.value)
|
||||
if (e.key === 'Escape') setIsEditing(false)
|
||||
}}
|
||||
onBlur={(e) => handleRename(currentCanvas.id, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-sm font-semibold text-foreground hover:text-primary transition-colors"
|
||||
title={t('labHeader.rename') || 'Rename'}
|
||||
>
|
||||
{currentCanvas.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{currentCanvas && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(currentCanvas.id, currentCanvas.name)}
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl transition-all"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isPending}
|
||||
className="h-10 rounded-xl px-4 flex items-center gap-2 shadow-lg shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all outline-none"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('labHeader.new')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
41
keep-notes/components/lab/lab-skeleton.tsx
Normal file
41
keep-notes/components/lab/lab-skeleton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export function LabSkeleton() {
|
||||
return (
|
||||
<div className="flex-1 w-full h-full bg-slate-50 dark:bg-[#1a1c22] relative overflow-hidden">
|
||||
{/* Mesh grid background simulation */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
|
||||
|
||||
{/* Top Menu Skeleton */}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
<Skeleton className="h-10 w-32 rounded-lg" />
|
||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||
</div>
|
||||
|
||||
{/* Style Menu Skeleton (Top Right) */}
|
||||
<div className="absolute top-4 right-4 flex flex-col gap-2">
|
||||
<Skeleton className="h-64 w-48 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar Skeleton (Bottom Center) */}
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-2 bg-white/50 dark:bg-black/20 backdrop-blur-md p-2 rounded-2xl border">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-10 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading Indicator */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 bg-white/80 dark:bg-[#252830]/80 p-8 rounded-3xl border shadow-2xl backdrop-blur-xl animate-in fade-in zoom-in duration-500">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h3 className="font-bold text-lg">Initialisation de l'espace</h3>
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Chargement de vos idées...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface MarkdownContentProps {
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Masonry Grid Styles — CSS columns natif (sans Muuri)
|
||||
* Layout responsive pur CSS, drag-and-drop via @dnd-kit
|
||||
* Masonry Grid — Deux modes d'affichage :
|
||||
* 1. Variable : CSS Grid avec tailles small/medium/large
|
||||
* 2. Uniform : CSS Columns masonry (comme Google Keep)
|
||||
*/
|
||||
|
||||
/* ─── Container ──────────────────────────────────── */
|
||||
@@ -9,13 +10,47 @@
|
||||
padding: 0 8px 40px 8px;
|
||||
}
|
||||
|
||||
/* ─── CSS Grid Masonry ───────────────────────────── */
|
||||
/* ═══════════════════════════════════════════════════
|
||||
MODE 1 : VARIABLE (CSS Grid avec tailles différentes)
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
.masonry-css-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
grid-auto-rows: auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
grid-auto-flow: dense;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="medium"] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="large"] {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════
|
||||
MODE 2 : UNIFORM — CSS Columns masonry (Google Keep)
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
.masonry-container[data-card-size-mode="uniform"] .masonry-css-grid {
|
||||
display: block;
|
||||
column-width: 240px;
|
||||
column-gap: 12px;
|
||||
orphans: 1;
|
||||
widows: 1;
|
||||
}
|
||||
|
||||
.masonry-container[data-card-size-mode="uniform"] .masonry-sortable-item,
|
||||
.masonry-container[data-card-size-mode="uniform"] .masonry-sortable-item[data-size="medium"],
|
||||
.masonry-container[data-card-size-mode="uniform"] .masonry-sortable-item[data-size="large"] {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 12px;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
grid-column: unset;
|
||||
}
|
||||
|
||||
/* ─── Sortable items ─────────────────────────────── */
|
||||
@@ -23,15 +58,14 @@
|
||||
break-inside: avoid;
|
||||
box-sizing: border-box;
|
||||
will-change: transform;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Notes "medium" et "large" occupent 2 colonnes si disponibles */
|
||||
.masonry-sortable-item[data-size="medium"] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="large"] {
|
||||
grid-column: span 2;
|
||||
/* ─── Note card base ─────────────────────────────── */
|
||||
.note-card {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ─── Drag overlay ───────────────────────────────── */
|
||||
@@ -43,32 +77,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Note card base ─────────────────────────────── */
|
||||
.note-card {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ─── Note size min-heights ──────────────────────── */
|
||||
.masonry-sortable-item[data-size="small"] .note-card {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="medium"] .note-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="large"] .note-card {
|
||||
min-height: 440px;
|
||||
}
|
||||
|
||||
/* ─── Transitions ────────────────────────────────── */
|
||||
.masonry-sortable-item {
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* ─── Mobile (< 480px) : 1 colonne ──────────────── */
|
||||
/* ─── Mobile (< 480px) ───────────────────────────── */
|
||||
@media (max-width: 479px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -80,24 +89,33 @@
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.masonry-container[data-card-size-mode="uniform"] .masonry-css-grid {
|
||||
column-width: 100%;
|
||||
column-gap: 10px;
|
||||
}
|
||||
|
||||
.masonry-container {
|
||||
padding: 0 4px 16px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Small tablet (480–767px) : 2 colonnes ─────── */
|
||||
/* ─── Small tablet (480–767px) ───────────────────── */
|
||||
@media (min-width: 480px) and (max-width: 767px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="large"] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.masonry-container {
|
||||
padding: 0 8px 20px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Tablet (768–1023px) : 2–3 colonnes ────────── */
|
||||
/* ─── Tablet (768–1023px) ────────────────────────── */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
@@ -105,7 +123,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Desktop (1024–1279px) : 3–4 colonnes ──────── */
|
||||
/* ─── Desktop (1024–1279px) ─────────────────────── */
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
@@ -113,7 +131,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Large Desktop (1280px+): 4–5 colonnes ─────── */
|
||||
/* ─── Large Desktop (1280px+) ───────────────────── */
|
||||
@media (min-width: 1280px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
@@ -140,4 +158,4 @@
|
||||
.masonry-sortable-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { NoteCard } from './note-card';
|
||||
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
|
||||
import { useNotebookDrag } from '@/context/notebook-drag-context';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
|
||||
import dynamic from 'next/dynamic';
|
||||
import './masonry-grid.css';
|
||||
|
||||
@@ -36,6 +37,8 @@ const NoteEditor = dynamic(
|
||||
interface MasonryGridProps {
|
||||
notes: Note[];
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
||||
isTrashView?: boolean;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -49,6 +52,7 @@ interface SortableNoteProps {
|
||||
onDragEndNote?: () => void;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
isTrashView?: boolean;
|
||||
}
|
||||
|
||||
const SortableNoteItem = memo(function SortableNoteItem({
|
||||
@@ -59,6 +63,7 @@ const SortableNoteItem = memo(function SortableNoteItem({
|
||||
onDragEndNote,
|
||||
isDragging,
|
||||
isOverlay,
|
||||
isTrashView,
|
||||
}: SortableNoteProps) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -91,6 +96,7 @@ const SortableNoteItem = memo(function SortableNoteItem({
|
||||
onDragStart={onDragStartNote}
|
||||
onDragEnd={onDragEndNote}
|
||||
isDragging={isDragging}
|
||||
isTrashView={isTrashView}
|
||||
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
||||
/>
|
||||
</div>
|
||||
@@ -107,6 +113,7 @@ interface SortableGridSectionProps {
|
||||
draggedNoteId: string | null;
|
||||
onDragStartNote: (noteId: string) => void;
|
||||
onDragEndNote: () => void;
|
||||
isTrashView?: boolean;
|
||||
}
|
||||
|
||||
const SortableGridSection = memo(function SortableGridSection({
|
||||
@@ -116,6 +123,7 @@ const SortableGridSection = memo(function SortableGridSection({
|
||||
draggedNoteId,
|
||||
onDragStartNote,
|
||||
onDragEndNote,
|
||||
isTrashView,
|
||||
}: SortableGridSectionProps) {
|
||||
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
||||
|
||||
@@ -131,6 +139,7 @@ const SortableGridSection = memo(function SortableGridSection({
|
||||
onDragStartNote={onDragStartNote}
|
||||
onDragEndNote={onDragEndNote}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
isTrashView={isTrashView}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -141,16 +150,28 @@ const SortableGridSection = memo(function SortableGridSection({
|
||||
// ─────────────────────────────────────────────
|
||||
// Main MasonryGrid component
|
||||
// ─────────────────────────────────────────────
|
||||
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: MasonryGridProps) {
|
||||
const { t } = useLanguage();
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
|
||||
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
|
||||
const cardSizeMode = useCardSizeMode();
|
||||
const isUniformMode = cardSizeMode === 'uniform';
|
||||
|
||||
// Local notes state for optimistic size/order updates
|
||||
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalNotes(notes);
|
||||
setLocalNotes(prev => {
|
||||
const prevIds = prev.map(n => n.id).join(',')
|
||||
const incomingIds = notes.map(n => n.id).join(',')
|
||||
if (prevIds === incomingIds) {
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
}
|
||||
// Notes added/removed: full sync but preserve local sizes
|
||||
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
|
||||
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
|
||||
})
|
||||
}, [notes]);
|
||||
|
||||
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
|
||||
@@ -172,7 +193,8 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
|
||||
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
|
||||
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
|
||||
}, []);
|
||||
onSizeChange?.(noteId, newSize);
|
||||
}, [onSizeChange]);
|
||||
|
||||
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
|
||||
const sensors = useSensors(
|
||||
@@ -225,7 +247,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="masonry-container">
|
||||
<div className="masonry-container" data-card-size-mode={cardSizeMode}>
|
||||
{pinnedNotes.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
|
||||
@@ -238,6 +260,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
isTrashView={isTrashView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -256,6 +279,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
isTrashView={isTrashView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -39,10 +39,15 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [demoMode, setDemoMode] = useState(false)
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Fetch insight on mount
|
||||
useEffect(() => {
|
||||
fetchInsight()
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchInsight = async () => {
|
||||
@@ -53,6 +58,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
if (data.insight) {
|
||||
setInsight(data.insight)
|
||||
// Check if user is in demo mode by looking at frequency settings
|
||||
setDemoMode(true) // If we got an insight after dismiss, assume demo mode
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to fetch insight:', error)
|
||||
@@ -61,6 +68,30 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling in demo mode after first dismiss
|
||||
useEffect(() => {
|
||||
if (isDismissed && !pollingRef.current) {
|
||||
pollingRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/ai/echo')
|
||||
const data = await res.json()
|
||||
if (data.insight) {
|
||||
setInsight(data.insight)
|
||||
setIsDismissed(false)
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, 15000) // Poll every 15s
|
||||
}
|
||||
return () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isDismissed])
|
||||
|
||||
const handleView = async () => {
|
||||
if (!insight) return
|
||||
|
||||
@@ -107,6 +138,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
// Dismiss notification
|
||||
setIsDismissed(true)
|
||||
// Stop polling after explicit feedback
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to submit feedback:', error)
|
||||
toast.error(t('toast.feedbackFailed'))
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Pin,
|
||||
Trash2,
|
||||
Users,
|
||||
Maximize2,
|
||||
FileText,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NOTE_COLORS } from "@/lib/types"
|
||||
@@ -31,6 +33,11 @@ interface NoteActionsProps {
|
||||
onSizeChange?: (size: 'small' | 'medium' | 'large') => void
|
||||
onDelete: () => void
|
||||
onShareCollaborators?: () => void
|
||||
isMarkdown?: boolean
|
||||
onToggleMarkdown?: () => void
|
||||
isTrashView?: boolean
|
||||
onRestore?: () => void
|
||||
onPermanentDelete?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -45,10 +52,49 @@ export function NoteActions({
|
||||
onSizeChange,
|
||||
onDelete,
|
||||
onShareCollaborators,
|
||||
isMarkdown = false,
|
||||
onToggleMarkdown,
|
||||
isTrashView,
|
||||
onRestore,
|
||||
onPermanentDelete,
|
||||
className
|
||||
}: NoteActionsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Trash view: show only Restore and Permanent Delete
|
||||
if (isTrashView) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Restore Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2 text-xs"
|
||||
onClick={onRestore}
|
||||
title={t('trash.restore')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('trash.restore')}</span>
|
||||
</Button>
|
||||
|
||||
{/* Permanent Delete Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 px-2 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
onClick={onPermanentDelete}
|
||||
title={t('trash.permanentDelete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('trash.permanentDelete')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-1", className)}
|
||||
@@ -79,6 +125,20 @@ export function NoteActions({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Markdown Toggle */}
|
||||
{onToggleMarkdown && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-8 gap-1 px-2 text-xs", isMarkdown && "text-primary bg-primary/10")}
|
||||
title="Markdown"
|
||||
onClick={onToggleMarkdown}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">MD</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* More Options */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
|
||||
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LogOut, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge } from '@/app/actions/notes'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, restoreNote, permanentDeleteNote, createNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -48,15 +48,19 @@ import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
import { CollaboratorDialog } from './collaborator-dialog'
|
||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode'
|
||||
import { CollaboratorAvatars } from './collaborator-avatars'
|
||||
import { ConnectionsBadge } from './connections-badge'
|
||||
import { ConnectionsOverlay } from './connections-overlay'
|
||||
import { ComparisonModal } from './comparison-modal'
|
||||
import { FusionModal } from './fusion-modal'
|
||||
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { toast } from 'sonner'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
// Mapping of supported languages to date-fns locales
|
||||
const localeMap: Record<string, Locale> = {
|
||||
@@ -81,28 +85,6 @@ function getDateLocale(language: string): Locale {
|
||||
return localeMap[language] || enUS
|
||||
}
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
function getNotebookIcon(iconName: string): LucideIcon {
|
||||
const IconComponent = ICON_MAP[iconName] || Folder
|
||||
return IconComponent
|
||||
}
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
@@ -112,6 +94,7 @@ interface NoteCardProps {
|
||||
onDragEnd?: () => void
|
||||
onResize?: () => void
|
||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
||||
isTrashView?: boolean
|
||||
}
|
||||
|
||||
// Helper function to get initials from name
|
||||
@@ -149,22 +132,26 @@ export const NoteCard = memo(function NoteCard({
|
||||
onDragEnd,
|
||||
isDragging,
|
||||
onResize,
|
||||
onSizeChange
|
||||
onSizeChange,
|
||||
isTrashView
|
||||
}: NoteCardProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refreshLabels } = useLabels()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
const { data: session } = useSession()
|
||||
const { t, language } = useLanguage()
|
||||
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
|
||||
const [, startTransition] = useTransition()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showPermanentDeleteDialog, setShowPermanentDeleteDialog] = useState(false)
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const [collaborators, setCollaborators] = useState<any[]>([])
|
||||
const [owner, setOwner] = useState<any>(null)
|
||||
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
|
||||
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
|
||||
|
||||
// Move note to a notebook
|
||||
@@ -198,6 +185,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
||||
|
||||
// Card size mode from settings
|
||||
const cardSizeMode = useCardSizeMode()
|
||||
const isUniformMode = cardSizeMode === 'uniform'
|
||||
|
||||
// Load collaborators only for shared notes (not owned by current user)
|
||||
useEffect(() => {
|
||||
// Skip API call for notes owned by current user — no need to fetch collaborators
|
||||
@@ -281,26 +272,16 @@ export const NoteCard = memo(function NoteCard({
|
||||
})
|
||||
}
|
||||
|
||||
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
|
||||
startTransition(async () => {
|
||||
// Instant visual feedback for the card itself
|
||||
addOptimisticNote({ size })
|
||||
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
|
||||
// Notifier le parent immédiatement (hors transition) — c'est lui
|
||||
// qui détient la source de vérité via localNotes
|
||||
onSizeChange?.(size)
|
||||
onResize?.()
|
||||
|
||||
// Notify parent so it can update its local state
|
||||
onSizeChange?.(size)
|
||||
|
||||
// Trigger layout refresh
|
||||
onResize?.()
|
||||
setTimeout(() => onResize?.(), 300)
|
||||
|
||||
// Update server in background
|
||||
|
||||
try {
|
||||
await updateSize(note.id, size);
|
||||
} catch (error) {
|
||||
console.error('Failed to update note size:', error);
|
||||
}
|
||||
})
|
||||
// Persister en arrière-plan
|
||||
updateSize(note.id, size).catch(err =>
|
||||
console.error('Failed to update note size:', err)
|
||||
)
|
||||
}
|
||||
|
||||
const handleCheckItem = async (checkItemId: string) => {
|
||||
@@ -327,6 +308,27 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
await restoreNote(note.id)
|
||||
setIsDeleting(true) // Hide the note from trash view
|
||||
toast.success(t('trash.noteRestored'))
|
||||
} catch (error) {
|
||||
console.error('Failed to restore note:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermanentDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await permanentDeleteNote(note.id)
|
||||
toast.success(t('trash.notePermanentlyDeleted'))
|
||||
} catch (error) {
|
||||
console.error('Failed to permanently delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent opening the note editor
|
||||
startTransition(async () => {
|
||||
@@ -353,10 +355,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
data-testid="note-card"
|
||||
data-draggable="true"
|
||||
data-note-id={note.id}
|
||||
data-size={optimisticNote.size}
|
||||
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
||||
draggable={true}
|
||||
data-size={isUniformMode ? 'small' : optimisticNote.size}
|
||||
style={{ minHeight: isUniformMode ? 'auto' : getMinHeight(optimisticNote.size) }}
|
||||
draggable={!isTrashView}
|
||||
onDragStart={(e) => {
|
||||
if (isTrashView) return
|
||||
e.dataTransfer.setData('text/plain', note.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
||||
@@ -382,7 +385,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
||||
{/* Drag Handle - Only visible on mobile/touch devices, not in trash */}
|
||||
{!isTrashView && (
|
||||
<div
|
||||
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
|
||||
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
|
||||
@@ -390,8 +394,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Move to Notebook Dropdown Menu */}
|
||||
{/* Move to Notebook Dropdown Menu - Hidden in trash */}
|
||||
{!isTrashView && (
|
||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -427,8 +433,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pin Button - Visible on hover or if pinned */}
|
||||
{/* Pin Button - Visible on hover or if pinned, hidden in trash */}
|
||||
{!isTrashView && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -446,6 +454,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@@ -602,19 +611,22 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar Component - Always show for now to fix regression */}
|
||||
{true && (
|
||||
{/* Action Bar Component - hide destructive actions for shared notes */}
|
||||
{!isSharedNote && (
|
||||
<NoteActions
|
||||
isPinned={optimisticNote.isPinned}
|
||||
isArchived={optimisticNote.isArchived}
|
||||
currentColor={optimisticNote.color}
|
||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
||||
currentSize={isUniformMode ? 'small' : (optimisticNote.size as 'small' | 'medium' | 'large')}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onColorChange={handleColorChange}
|
||||
onSizeChange={handleSizeChange}
|
||||
onSizeChange={isUniformMode ? undefined : handleSizeChange}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
||||
isTrashView={isTrashView}
|
||||
onRestore={handleRestore}
|
||||
onPermanentDelete={() => setShowPermanentDeleteDialog(true)}
|
||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
@@ -638,13 +650,25 @@ export const NoteCard = memo(function NoteCard({
|
||||
isOpen={showConnectionsOverlay}
|
||||
onClose={() => setShowConnectionsOverlay(false)}
|
||||
noteId={note.id}
|
||||
onOpenNote={(noteId) => {
|
||||
// Find the note and open it
|
||||
onEdit?.(note, false)
|
||||
onOpenNote={(connNoteId) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('note', connNoteId)
|
||||
router.push(`?${params.toString()}`)
|
||||
}}
|
||||
onCompareNotes={(noteIds) => {
|
||||
setComparisonNotes(noteIds)
|
||||
}}
|
||||
onMergeNotes={async (noteIds) => {
|
||||
const fetchedNotes = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -665,6 +689,38 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes && fusionNotes.length > 0 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FusionModal
|
||||
isOpen={!!fusionNotes}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={async ({ title, content }, options) => {
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
||||
: fusionNotes[0].labels || [],
|
||||
color: fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
triggerRefresh()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
@@ -682,6 +738,24 @@ export const NoteCard = memo(function NoteCard({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Permanent Delete Confirmation Dialog (Trash view only) */}
|
||||
<AlertDialog open={showPermanentDeleteDialog} onOpenChange={setShowPermanentDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('trash.permanentDelete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('trash.permanentDeleteConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handlePermanentDelete}>
|
||||
{t('trash.permanentDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2 } from 'lucide-react'
|
||||
import { updateNote, createNote } from '@/app/actions/notes'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
|
||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -66,6 +66,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
const [color, setColor] = useState(note.color)
|
||||
const [size, setSize] = useState<NoteSize>(note.size || 'small')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
||||
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -175,7 +176,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
}
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
const removedUrl = images[index]
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
// Track removed images for cleanup on save
|
||||
if (removedUrl) {
|
||||
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLink = async () => {
|
||||
@@ -483,6 +489,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
size,
|
||||
})
|
||||
|
||||
// Clean up removed image files from disk (best-effort, don't block save)
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
}
|
||||
|
||||
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
|
||||
await refreshLabels()
|
||||
|
||||
@@ -989,6 +1000,23 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('notes.makeCopy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{t('notes.leaveShare')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('general.close')}
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -24,6 +27,9 @@ import {
|
||||
toggleArchive,
|
||||
updateColor,
|
||||
deleteNote,
|
||||
removeImageFromNote,
|
||||
leaveSharedNote,
|
||||
createNote,
|
||||
} from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import {
|
||||
@@ -49,6 +55,8 @@ import {
|
||||
RotateCcw,
|
||||
Languages,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
@@ -58,6 +66,7 @@ import { GhostTags } from '@/components/ghost-tags'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -99,8 +108,11 @@ export function NoteInlineEditor({
|
||||
defaultPreviewMode = false,
|
||||
}: NoteInlineEditorProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { labels: globalLabels, addLabel } = useLabels()
|
||||
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
||||
const [, startTransition] = useTransition()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const isSharedNote = !!(note as any)._isShared
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
@@ -113,11 +125,41 @@ export function NoteInlineEditor({
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||
|
||||
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
|
||||
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
|
||||
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
|
||||
|
||||
// Textarea ref for formatting toolbar
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const applyFormat = (prefix: string, suffix: string = prefix) => {
|
||||
const textarea = textAreaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = content.substring(start, end)
|
||||
const before = content.substring(0, start)
|
||||
const after = content.substring(end)
|
||||
|
||||
const newContent = before + prefix + selected + suffix + after
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
|
||||
// Restore cursor position after React re-renders
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
const newCursorPos = selected ? end + prefix.length + suffix.length : start + prefix.length
|
||||
textarea.setSelectionRange(
|
||||
selected ? start + prefix.length : start + prefix.length,
|
||||
selected ? end + prefix.length : newCursorPos
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Link dialog
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
@@ -230,12 +272,103 @@ export function NoteInlineEditor({
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try { await addLabel(tag) } catch {}
|
||||
try {
|
||||
await addLabel(tag)
|
||||
// Refresh labels to get the new color assignment
|
||||
await refreshLabels()
|
||||
} catch {}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLabel = async (label: string) => {
|
||||
const newLabels = (note.labels || []).filter((l) => l !== label)
|
||||
// Optimistic UI
|
||||
onChange?.(note.id, { labels: newLabels })
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
toast.success(t('labels.labelRemoved', { label }))
|
||||
}
|
||||
|
||||
// ── Shared note actions ────────────────────────────────────────────────────
|
||||
const handleMakeCopy = async () => {
|
||||
try {
|
||||
await createNote({
|
||||
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||
content,
|
||||
color: note.color,
|
||||
type: note.type,
|
||||
checkItems: note.checkItems ?? undefined,
|
||||
labels: note.labels ?? undefined,
|
||||
images: note.images ?? undefined,
|
||||
links: note.links ?? undefined,
|
||||
isMarkdown,
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
triggerRefresh()
|
||||
} catch (error) {
|
||||
toast.error(t('notes.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
onDelete?.(note.id)
|
||||
} catch (error) {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
const handleCompareNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setComparisonNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
||||
: fusionNotes[0].labels || [],
|
||||
color: fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
triggerRefresh()
|
||||
}
|
||||
|
||||
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
||||
const handleTogglePin = () => {
|
||||
startTransition(async () => {
|
||||
@@ -262,10 +395,21 @@ export function NoteInlineEditor({
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(t('notes.confirmDelete'))) return
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
toast(t('notes.confirmDelete'), {
|
||||
action: {
|
||||
label: t('notes.delete'),
|
||||
onClick: () => {
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
})
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t('common.cancel'),
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -293,7 +437,7 @@ export function NoteInlineEditor({
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
await removeImageFromNote(note.id, index)
|
||||
}
|
||||
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
@@ -437,7 +581,27 @@ export function NoteInlineEditor({
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
|
||||
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
|
||||
{/* ── Shared note banner ──────────────────────────────────────────── */}
|
||||
{isSharedNote && (
|
||||
<div className="flex items-center justify-between border-b border-border/30 bg-primary/5 dark:bg-primary/10 px-4 py-2">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{t('notes.sharedReadOnly') || 'Lecture seule — note partagée'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="default" size="sm" className="h-7 gap-1.5 text-xs" onClick={handleMakeCopy}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t('notes.makeCopy') || 'Copier'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30" onClick={handleLeaveShare}>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t('notes.leaveShare') || 'Quitter'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Toolbar (hidden for shared notes) ────────────────────────────── */}
|
||||
{!isSharedNote && (
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image upload */}
|
||||
@@ -625,12 +789,6 @@ export function NoteInlineEditor({
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Pin */}
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
|
||||
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
|
||||
</Button>
|
||||
|
||||
{/* Color picker */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -677,6 +835,7 @@ export function NoteInlineEditor({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
||||
{showLinkInput && (
|
||||
@@ -704,7 +863,11 @@ export function NoteInlineEditor({
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
|
||||
{/* Existing labels */}
|
||||
{(note.labels ?? []).map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => handleRemoveLabel(label)}
|
||||
/>
|
||||
))}
|
||||
{/* AI-suggested tags inline with labels */}
|
||||
<GhostTags
|
||||
@@ -728,6 +891,7 @@ export function NoteInlineEditor({
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
/>
|
||||
{/* AI title suggestion — show when title is empty and there's content */}
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
@@ -812,17 +976,21 @@ export function NoteInlineEditor({
|
||||
<MarkdownContent content={content || ''} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
<>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
||||
@@ -881,6 +1049,15 @@ export function NoteInlineEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section (not for shared notes) ── */}
|
||||
{!isSharedNote && (
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||
@@ -891,6 +1068,25 @@ export function NoteInlineEditor({
|
||||
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes.length > 0 && (
|
||||
<FusionModal
|
||||
isOpen={fusionNotes.length > 0}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={handleConfirmFusion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comparison Modal */}
|
||||
{comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen={comparisonNotes.length > 0}
|
||||
onClose={() => setComparisonNotes([])}
|
||||
notes={comparisonNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function NoteInput({
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Simple state without complex undo/redo - like Google Keep
|
||||
// Simple state without complex undo/redo
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, FolderOpen } from 'lucide-react'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
interface NotebookSuggestionToastProps {
|
||||
noteId: string
|
||||
@@ -121,8 +122,12 @@ export function NotebookSuggestionToast({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t('notebookSuggestion.title', { icon: suggestion.icon, name: suggestion.name })}
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(suggestion.icon)
|
||||
return <Icon className="w-4 h-4" />
|
||||
})()}
|
||||
{t('notebookSuggestion.title', { name: suggestion.name })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('notebookSuggestion.description')}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { StickyNote, Plus, Tag, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LucideIcon, Plane, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { useNotebookDrag } from '@/context/notebook-drag-context'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -17,28 +17,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
||||
import { Notebook } from '@/lib/types'
|
||||
|
||||
// Map icon names to lucide-react components
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
return ICON_MAP[iconName] || Folder
|
||||
}
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
export function NotebooksList() {
|
||||
const pathname = usePathname()
|
||||
|
||||
@@ -23,10 +23,11 @@ interface NotesMainSectionProps {
|
||||
notes: Note[]
|
||||
viewMode: NotesViewMode
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
|
||||
currentNotebookId?: string | null
|
||||
}
|
||||
|
||||
export function NotesMainSection({ notes, viewMode, onEdit, currentNotebookId }: NotesMainSectionProps) {
|
||||
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
|
||||
if (viewMode === 'tabs') {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
|
||||
@@ -37,7 +38,7 @@ export function NotesMainSection({ notes, viewMode, onEdit, currentNotebookId }:
|
||||
|
||||
return (
|
||||
<div data-testid="notes-grid">
|
||||
<MasonryGridLazy notes={notes} onEdit={onEdit} />
|
||||
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'
|
||||
import { NoteInlineEditor } from '@/components/note-inline-editor'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { getNoteDisplayTitle } from '@/lib/note-preview'
|
||||
import { updateFullOrderWithoutRevalidation, createNote } from '@/app/actions/notes'
|
||||
import { updateFullOrderWithoutRevalidation, createNote, deleteNote } from '@/app/actions/notes'
|
||||
import {
|
||||
GripVertical,
|
||||
Hash,
|
||||
@@ -33,8 +33,17 @@ import {
|
||||
Clock,
|
||||
Plus,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
@@ -105,14 +114,18 @@ function SortableNoteListItem({
|
||||
note,
|
||||
selected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
reorderLabel,
|
||||
deleteLabel,
|
||||
language,
|
||||
untitledLabel,
|
||||
}: {
|
||||
note: Note
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
reorderLabel: string
|
||||
deleteLabel: string
|
||||
language: string
|
||||
untitledLabel: string
|
||||
}) {
|
||||
@@ -231,6 +244,20 @@ function SortableNoteListItem({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete button - visible on hover */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="flex items-center px-2 text-red-500/60 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -242,6 +269,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
const [items, setItems] = useState<Note[]>(notes)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [isCreating, startCreating] = useTransition()
|
||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Only reset when notes are added or removed, NOT on content/field changes
|
||||
@@ -254,7 +282,15 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
return prev.map((p) => {
|
||||
const fresh = notes.find((n) => n.id === p.id)
|
||||
if (!fresh) return p
|
||||
return { ...fresh, title: p.title, content: p.content, labels: p.labels }
|
||||
// Use fresh labels from server if they've changed (e.g., global label deletion)
|
||||
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
|
||||
return {
|
||||
...fresh,
|
||||
title: p.title,
|
||||
content: p.content,
|
||||
// Always use server labels if different (for global label changes)
|
||||
labels: labelsChanged ? fresh.labels : p.labels
|
||||
}
|
||||
})
|
||||
}
|
||||
// Different set (add/remove): full sync
|
||||
@@ -386,7 +422,9 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
note={note}
|
||||
selected={note.id === selectedId}
|
||||
onSelect={() => setSelectedId(note.id)}
|
||||
onDelete={() => setNoteToDelete(note)}
|
||||
reorderLabel={t('notes.reorderTabs')}
|
||||
deleteLabel={t('notes.delete')}
|
||||
language={language}
|
||||
untitledLabel={t('notes.untitled')}
|
||||
/>
|
||||
@@ -430,6 +468,45 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
<p className="text-sm">{t('notes.selectNote') || 'Sélectionnez une note'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!noteToDelete} onOpenChange={() => setNoteToDelete(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
|
||||
{noteToDelete && (
|
||||
<span className="mt-2 block font-medium text-foreground">
|
||||
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNoteToDelete(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (!noteToDelete) return
|
||||
try {
|
||||
await deleteNote(noteToDelete.id)
|
||||
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
|
||||
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
|
||||
setNoteToDelete(null)
|
||||
toast.success(t('notes.deleted'))
|
||||
} catch {
|
||||
toast.error(t('notes.deleteFailed'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('notes.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Bell, Check, X, Clock, User } from 'lucide-react'
|
||||
import { Bell, Check, X, Clock } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getPendingShareRequests, respondToShareRequest, removeSharedNoteFromView } from '@/app/actions/notes'
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { getPendingShareRequests, respondToShareRequest } from '@/app/actions/notes'
|
||||
import { toast } from 'sonner'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -40,37 +40,40 @@ export function NotificationPanel() {
|
||||
const { t } = useLanguage()
|
||||
const [requests, setRequests] = useState<ShareRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const loadRequests = async () => {
|
||||
setIsLoading(true)
|
||||
const loadRequests = useCallback(async () => {
|
||||
try {
|
||||
const data = await getPendingShareRequests()
|
||||
setRequests(data)
|
||||
setPendingCount(data.length)
|
||||
setRequests(data as any)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load share requests:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests()
|
||||
const interval = setInterval(loadRequests, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
const interval = setInterval(loadRequests, 10000)
|
||||
const onFocus = () => loadRequests()
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
}
|
||||
}, [loadRequests])
|
||||
|
||||
const pendingCount = requests.length
|
||||
|
||||
const handleAccept = async (shareId: string) => {
|
||||
try {
|
||||
await respondToShareRequest(shareId, 'accept')
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
setPendingCount(prev => prev - 1)
|
||||
triggerRefresh()
|
||||
toast.success(t('notes.noteCreated'), {
|
||||
toast.success(t('notification.accepted'), {
|
||||
description: t('collaboration.nowHasAccess', { name: 'Note' }),
|
||||
duration: 3000,
|
||||
})
|
||||
triggerRefresh()
|
||||
setOpen(false)
|
||||
} catch (error: any) {
|
||||
console.error('[NOTIFICATION] Error:', error)
|
||||
toast.error(error.message || t('general.error'))
|
||||
@@ -81,27 +84,17 @@ export function NotificationPanel() {
|
||||
try {
|
||||
await respondToShareRequest(shareId, 'decline')
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
setPendingCount(prev => prev - 1)
|
||||
toast.info(t('notification.declined'))
|
||||
if (requests.length <= 1) setOpen(false)
|
||||
} catch (error: any) {
|
||||
console.error('[NOTIFICATION] Error:', error)
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (shareId: string) => {
|
||||
try {
|
||||
await removeSharedNoteFromView(shareId)
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
toast.info(t('notification.removed'))
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -117,8 +110,8 @@ export function NotificationPanel() {
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="px-4 py-3 border-b bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -136,12 +129,11 @@ export function NotificationPanel() {
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
|
||||
{t('general.loading')}
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">{t('search.noResults')}</p>
|
||||
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
@@ -151,7 +143,7 @@ export function NotificationPanel() {
|
||||
className="p-4 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-xs shadow-md">
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-xs shadow-md shrink-0">
|
||||
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -162,63 +154,50 @@ export function NotificationPanel() {
|
||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs capitalize bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground border-0"
|
||||
>
|
||||
{request.permission}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleAccept(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
|
||||
"bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700",
|
||||
"text-white shadow-md hover:shadow-lg",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"active:scale-95"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('general.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecline(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
|
||||
"bg-white dark:bg-gray-800",
|
||||
"border-2 border-gray-200 dark:border-gray-700",
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
"hover:border-gray-300 dark:hover:border-gray-600",
|
||||
"border border-border bg-background",
|
||||
"text-muted-foreground",
|
||||
"hover:bg-muted hover:text-foreground",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"active:scale-95"
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
{t('general.cancel')}
|
||||
{t('notification.decline') || t('general.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAccept(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
|
||||
"bg-primary text-primary-foreground",
|
||||
"hover:bg-primary/90",
|
||||
"shadow-sm hover:shadow",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"active:scale-95"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('notification.accept') || t('general.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{new Date(request.createdAt).toLocaleDateString()}</span>
|
||||
<button
|
||||
onClick={() => handleRemove(request.id)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
{t('general.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
|
||||
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
import { LabelProvider } from '@/context/LabelContext'
|
||||
import { NotebooksProvider } from '@/context/notebooks-context'
|
||||
import { NotebookDragProvider } from '@/context/notebook-drag-context'
|
||||
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
|
||||
import { HomeViewProvider } from '@/context/home-view-context'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Translations } from '@/lib/i18n/load-translations'
|
||||
|
||||
const RTL_LANGUAGES = ['ar', 'fa']
|
||||
|
||||
/** Sets `dir` on its own DOM node from React state — immune to third-party JS overwriting documentElement.dir. */
|
||||
function DirWrapper({ children }: { children: ReactNode }) {
|
||||
const { language } = useLanguage()
|
||||
const dir = RTL_LANGUAGES.includes(language) ? 'rtl' : 'ltr'
|
||||
return <div dir={dir} className="contents">{children}</div>
|
||||
}
|
||||
|
||||
interface ProvidersWrapperProps {
|
||||
children: ReactNode
|
||||
initialLanguage?: string
|
||||
initialTranslations?: Translations
|
||||
}
|
||||
|
||||
export function ProvidersWrapper({ children, initialLanguage = 'en' }: ProvidersWrapperProps) {
|
||||
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
|
||||
return (
|
||||
<NoteRefreshProvider>
|
||||
<LabelProvider>
|
||||
<NotebooksProvider>
|
||||
<NotebookDragProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage as any}>
|
||||
<HomeViewProvider>{children}</HomeViewProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
||||
<DirWrapper>
|
||||
<HomeViewProvider>{children}</HomeViewProvider>
|
||||
</DirWrapper>
|
||||
</LanguageProvider>
|
||||
</NotebookDragProvider>
|
||||
</NotebooksProvider>
|
||||
|
||||
@@ -105,17 +105,27 @@ function CompactCard({
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(t('notes.confirmDelete'))) {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
triggerRefresh()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
toast(t('notes.confirmDelete'), {
|
||||
action: {
|
||||
label: t('notes.delete'),
|
||||
onClick: async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
triggerRefresh()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t('common.cancel'),
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDismiss = async (e: React.MouseEvent) => {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingInputProps {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
type?: 'text' | 'password' | 'email' | 'url'
|
||||
onChange: (value: string) => Promise<void>
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SettingInput({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
type = 'text',
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled
|
||||
}: SettingInputProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaved, setIsSaved] = useState(false)
|
||||
|
||||
const handleChange = async (newValue: string) => {
|
||||
setIsLoading(true)
|
||||
setIsSaved(false)
|
||||
|
||||
try {
|
||||
await onChange(newValue)
|
||||
setIsSaved(true)
|
||||
toast.success(t('toast.saved'))
|
||||
|
||||
setTimeout(() => setIsSaved(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
|
||||
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 border rounded-lg',
|
||||
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'bg-white dark:bg-gray-900',
|
||||
'border-gray-300 dark:border-gray-700',
|
||||
'text-gray-900 dark:text-gray-100',
|
||||
'placeholder:text-gray-400 dark:placeholder:text-gray-600'
|
||||
)}
|
||||
/>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
|
||||
)}
|
||||
{isSaved && !isLoading && (
|
||||
<Check className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface SettingSelectProps {
|
||||
label: string
|
||||
description?: string
|
||||
value: string
|
||||
options: SelectOption[]
|
||||
onChange: (value: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SettingSelect({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled
|
||||
}: SettingSelectProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleChange = async (newValue: string) => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await onChange(newValue)
|
||||
toast.success(t('toast.saved'))
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
|
||||
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 border rounded-lg',
|
||||
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'appearance-none bg-white dark:bg-gray-900',
|
||||
'border-gray-300 dark:border-gray-700',
|
||||
'text-gray-900 dark:text-gray-100'
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isLoading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Loader2, Check, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingToggleProps {
|
||||
label: string
|
||||
description?: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => Promise<void>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SettingToggle({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled
|
||||
}: SettingToggleProps) {
|
||||
const { t } = useLanguage()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const handleChange = async (newChecked: boolean) => {
|
||||
setIsLoading(true)
|
||||
setError(false)
|
||||
|
||||
try {
|
||||
await onChange(newChecked)
|
||||
toast.success(t('toast.saved'))
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err)
|
||||
setError(true)
|
||||
toast.error(t('toast.saveFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center justify-between py-4',
|
||||
'border-b last:border-0 dark:border-gray-800'
|
||||
)}>
|
||||
<div className="flex-1 pr-4">
|
||||
<Label className="font-medium text-gray-900 dark:text-gray-100 cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-gray-500" />}
|
||||
{!isLoading && !error && checked && <Check className="h-4 w-4 text-green-500" />}
|
||||
{!isLoading && !error && !checked && <X className="h-4 w-4 text-gray-400" />}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled || isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
interface SettingsSection {
|
||||
id: string
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
|
||||
interface SettingsNavProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsNav({ className }: SettingsNavProps) {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
const sections: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: t('generalSettings.title'),
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
href: '/settings/general'
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
label: t('aiSettings.title'),
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
href: '/settings/ai'
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
label: t('appearance.title'),
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
href: '/settings/appearance'
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
label: t('profile.title'),
|
||||
icon: <User className="h-5 w-5" />,
|
||||
href: '/settings/profile'
|
||||
},
|
||||
{
|
||||
id: 'data',
|
||||
label: t('dataManagement.title'),
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
href: '/settings/data'
|
||||
},
|
||||
{
|
||||
id: 'mcp',
|
||||
label: t('mcpSettings.title'),
|
||||
icon: <Key className="h-5 w-5" />,
|
||||
href: '/settings/mcp'
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
label: t('about.title'),
|
||||
icon: <Info className="h-5 w-5" />,
|
||||
href: '/settings/about'
|
||||
}
|
||||
]
|
||||
|
||||
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
|
||||
|
||||
return (
|
||||
<nav className={cn('space-y-1', className)}>
|
||||
{sections.map((section) => (
|
||||
<Link
|
||||
key={section.id}
|
||||
href={section.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
isActive(section.href)
|
||||
? 'bg-gray-100 dark:bg-gray-800 text-primary'
|
||||
: 'text-gray-700 dark:text-gray-300'
|
||||
)}
|
||||
>
|
||||
{isActive(section.href) && (
|
||||
<Check className="h-4 w-4 text-primary" />
|
||||
)}
|
||||
{!isActive(section.href) && (
|
||||
<div className="w-4" />
|
||||
)}
|
||||
{section.icon}
|
||||
<span className="font-medium">{section.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface Section {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
icon: React.ReactNode
|
||||
href: string
|
||||
}
|
||||
|
||||
interface SettingsSearchProps {
|
||||
sections: Section[]
|
||||
onFilter: (filteredSections: Section[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsSearch({
|
||||
sections,
|
||||
onFilter,
|
||||
placeholder,
|
||||
className
|
||||
}: SettingsSearchProps) {
|
||||
const { t } = useLanguage()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
|
||||
|
||||
const searchPlaceholder = placeholder || t('settings.searchNoResults') || 'Search settings...'
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setFilteredSections(sections)
|
||||
return
|
||||
}
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
const filtered = sections.filter(section => {
|
||||
const labelMatch = section.label.toLowerCase().includes(queryLower)
|
||||
const descMatch = section.description.toLowerCase().includes(queryLower)
|
||||
return labelMatch || descMatch
|
||||
})
|
||||
setFilteredSections(filtered)
|
||||
}, [query, sections])
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setQuery('')
|
||||
setFilteredSections(sections)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClearSearch()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setQuery(value)
|
||||
}
|
||||
|
||||
const hasResults = query.trim() && filteredSections.length < sections.length
|
||||
const isEmptySearch = query.trim() && filteredSections.length === 0
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
{hasResults && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label={t('search.placeholder')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{isEmptySearch && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 p-2 bg-white rounded-lg shadow-lg border z-50">
|
||||
<p className="text-sm text-gray-600">{t('settings.searchNoResults')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
className
|
||||
}: SettingsSectionProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</CardTitle>
|
||||
{description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { SettingsNav } from './SettingsNav'
|
||||
export { SettingsSection } from './SettingsSection'
|
||||
export { SettingToggle } from './SettingToggle'
|
||||
export { SettingSelect } from './SettingSelect'
|
||||
export { SettingInput } from './SettingInput'
|
||||
export { SettingsSearch } from './SettingsSearch'
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Lightbulb,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
X,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -21,12 +23,51 @@ import {
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NotebooksList } from './notebooks-list'
|
||||
import { useHomeViewOptional } from '@/context/home-view-context'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getTrashCount } from '@/app/actions/notes'
|
||||
|
||||
const HIDDEN_ROUTES = ['/agents', '/chat', '/lab']
|
||||
|
||||
export function Sidebar({ className, user }: { className?: string, user?: any }) {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const homeBridge = useHomeViewOptional()
|
||||
const [trashCount, setTrashCount] = useState(0)
|
||||
|
||||
// Fetch trash count
|
||||
useEffect(() => {
|
||||
getTrashCount().then(setTrashCount)
|
||||
}, [pathname, searchParams])
|
||||
|
||||
// Hide sidebar on Agents, Chat IA and Lab routes
|
||||
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
|
||||
|
||||
// Active label filter
|
||||
const activeLabel = searchParams.get('label')
|
||||
const activeLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
|
||||
const clearLabelFilter = () => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('label')
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
const clearLabelsFilter = (labelToRemove?: string) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
if (labelToRemove) {
|
||||
const remaining = activeLabels.filter(l => l !== labelToRemove)
|
||||
if (remaining.length > 0) {
|
||||
params.set('labels', remaining.join(','))
|
||||
} else {
|
||||
params.delete('labels')
|
||||
}
|
||||
} else {
|
||||
params.delete('labels')
|
||||
}
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
// Helper to determine if a link is active
|
||||
const isActive = (href: string, exact = false) => {
|
||||
@@ -50,7 +91,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
return pathname === href
|
||||
}
|
||||
|
||||
const NavItem = ({ href, icon: Icon, label, active }: any) => (
|
||||
const NavItem = ({ href, icon: Icon, label, active, badge }: any) => (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
@@ -63,6 +104,16 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
>
|
||||
<Icon className={cn("w-5 h-5", active ? "fill-current" : "")} />
|
||||
<span className="truncate">{label}</span>
|
||||
{badge > 0 && (
|
||||
<span className={cn(
|
||||
"ms-auto text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[20px] text-center",
|
||||
active
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -112,7 +163,42 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
<NotebooksList />
|
||||
</div>
|
||||
|
||||
|
||||
{/* Active Label Filter Chips */}
|
||||
{pathname === '/' && (activeLabel || activeLabels.length > 0) && (
|
||||
<div className="px-4 pt-2 flex flex-col gap-1">
|
||||
{activeLabel && (
|
||||
<div className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground">
|
||||
<Tag className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">{activeLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearLabelFilter}
|
||||
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
|
||||
title={t('sidebar.clearFilter') || 'Remove filter'}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeLabels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium truncate flex-1">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearLabelsFilter(label)}
|
||||
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
|
||||
title={t('sidebar.clearFilter') || 'Remove filter'}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archive & Trash */}
|
||||
<div className="flex flex-col mt-2 border-t border-transparent">
|
||||
@@ -127,6 +213,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
icon={Trash2}
|
||||
label={t('sidebar.trash') || 'Corbeille'}
|
||||
active={pathname === '/trash'}
|
||||
badge={trashCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
78
keep-notes/components/trash-header.tsx
Normal file
78
keep-notes/components/trash-header.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { emptyTrash } from '@/app/actions/notes'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface TrashHeaderProps {
|
||||
noteCount?: number
|
||||
}
|
||||
|
||||
export function TrashHeader({ noteCount = 0 }: TrashHeaderProps) {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [showEmptyDialog, setShowEmptyDialog] = useState(false)
|
||||
const [isEmptying, setIsEmptying] = useState(false)
|
||||
|
||||
const handleEmptyTrash = async () => {
|
||||
setIsEmptying(true)
|
||||
try {
|
||||
await emptyTrash()
|
||||
toast.success(t('trash.emptyTrashSuccess'))
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Error emptying trash:', error)
|
||||
} finally {
|
||||
setIsEmptying(false)
|
||||
setShowEmptyDialog(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold">{t('nav.trash')}</h1>
|
||||
{noteCount > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowEmptyDialog(true)}
|
||||
disabled={isEmptying}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t('trash.emptyTrash')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showEmptyDialog} onOpenChange={setShowEmptyDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('trash.emptyTrash')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('trash.emptyTrashConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleEmptyTrash}>
|
||||
{t('trash.emptyTrash')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
keep-notes/components/ui/combobox.tsx
Normal file
125
keep-notes/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Check, ChevronDown, Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
interface ComboboxOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: ComboboxOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyMessage?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No results found.',
|
||||
disabled = false,
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [search, setSearch] = React.useState('')
|
||||
|
||||
const selectedLabel = options.find((o) => o.value === value)?.label
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!search.trim()) return options
|
||||
const q = search.toLowerCase()
|
||||
return options.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(q) ||
|
||||
o.value.toLowerCase().includes(q)
|
||||
)
|
||||
}, [options, search])
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onChange(optionValue === value ? '' : optionValue)
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'hover:bg-accent hover:text-accent-foreground transition-colors',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!value && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{selectedLabel || placeholder}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
className="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((option) => {
|
||||
const isSelected = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'hover:bg-accent hover:text-accent-foreground transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
155
keep-notes/components/ui/command.tsx
Normal file
155
keep-notes/components/ui/command.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
15
keep-notes/components/ui/skeleton.tsx
Normal file
15
keep-notes/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-muted animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Masonry Layout Configuration
|
||||
*
|
||||
* Configuration for responsive masonry grid layout similar to Google Keep
|
||||
* Configuration for responsive masonry grid layout
|
||||
* Defines breakpoints, columns, and note sizes for different screen sizes
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface MasonryLayoutConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default layout configuration based on Google Keep's behavior
|
||||
* Default layout configuration
|
||||
*
|
||||
* Responsive breakpoints:
|
||||
* - Mobile (< 480px): 1 column
|
||||
|
||||
47
keep-notes/debug-openrouter.ts
Normal file
47
keep-notes/debug-openrouter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function run() {
|
||||
const configs = await prisma.systemConfig.findMany();
|
||||
const config = configs.reduce((acc, c) => ({...acc, [c.key]: c.value}), {} as any);
|
||||
|
||||
const customClient = createOpenAI({
|
||||
baseURL: config.CUSTOM_OPENAI_BASE_URL || 'https://openrouter.ai/api/v1/',
|
||||
apiKey: config.CUSTOM_OPENAI_API_KEY,
|
||||
compatibility: 'compatible',
|
||||
fetch: async (url, options) => {
|
||||
const headers = new Headers(options?.headers);
|
||||
headers.set('HTTP-Referer', 'http://localhost:3000');
|
||||
headers.set('X-Title', 'Test');
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error("RAW HTTP ERROR FROM OPENROUTER:", text);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
});
|
||||
|
||||
const model = customClient(config.AI_MODEL_TAGS);
|
||||
|
||||
const messages: any = [
|
||||
{ role: 'user', content: 'System Rules\n---\nhello' },
|
||||
{ role: 'assistant', content: 'Hello!' },
|
||||
{ role: 'user', content: 'dis moi...' }
|
||||
];
|
||||
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: model,
|
||||
messages: messages,
|
||||
});
|
||||
console.log("SUCCESS:", text);
|
||||
} catch (err: any) {
|
||||
console.error("SDK ERROR:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
37
keep-notes/hooks/use-card-size-mode.ts
Normal file
37
keep-notes/hooks/use-card-size-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
type CardSizeMode = 'variable' | 'uniform'
|
||||
|
||||
export function useCardSizeMode(): CardSizeMode {
|
||||
const [mode, setMode] = useState<CardSizeMode>('variable')
|
||||
|
||||
useEffect(() => {
|
||||
// Check localStorage first (for immediate UI response)
|
||||
const stored = localStorage.getItem('card-size-mode') as CardSizeMode | null
|
||||
if (stored && (stored === 'variable' || stored === 'uniform')) {
|
||||
setMode(stored)
|
||||
}
|
||||
|
||||
// Listen for storage changes (when user changes setting in another tab)
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === 'card-size-mode') {
|
||||
const newMode = e.newValue as CardSizeMode | null
|
||||
if (newMode && (newMode === 'variable' || newMode === 'uniform')) {
|
||||
setMode(newMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [])
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export function useIsUniformSize(): boolean {
|
||||
const mode = useCardSizeMode()
|
||||
return mode === 'uniform'
|
||||
}
|
||||
167
keep-notes/lib/agent-email-template.ts
Normal file
167
keep-notes/lib/agent-email-template.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
export interface EmailAttachment {
|
||||
filename: string
|
||||
content: Buffer
|
||||
cid: string
|
||||
}
|
||||
|
||||
interface AgentEmailParams {
|
||||
agentName: string
|
||||
content: string
|
||||
appUrl: string
|
||||
userName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a local image file from the public directory.
|
||||
*/
|
||||
async function readLocalImage(relativePath: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'public', relativePath)
|
||||
return await fs.readFile(filePath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown to simple HTML suitable for email clients.
|
||||
* Replaces local image references with cid: placeholders for inline attachments.
|
||||
* Returns the HTML and a list of attachments to include.
|
||||
*/
|
||||
export async function markdownToEmailHtml(md: string, appUrl: string): Promise<{ html: string; attachments: EmailAttachment[] }> {
|
||||
let html = md
|
||||
const attachments: EmailAttachment[] = []
|
||||
const baseUrl = appUrl.replace(/\/$/, '')
|
||||
|
||||
// Remove the execution footer (agent trace)
|
||||
html = html.replace(/\n---\n\n_\$Agent execution:[\s\S]*$/, '')
|
||||
html = html.replace(/\n---\n\n_Agent execution:[\s\S]*$/, '')
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---+$/gm, '<hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0;">')
|
||||
|
||||
// Headings
|
||||
html = html.replace(/^### (.+)$/gm, '<h3 style="margin:16px 0 8px;font-size:15px;font-weight:600;color:#1f2937;">$1</h3>')
|
||||
html = html.replace(/^## (.+)$/gm, '<h2 style="margin:20px 0 10px;font-size:16px;font-weight:700;color:#111827;">$1</h2>')
|
||||
html = html.replace(/^# (.+)$/gm, '<h1 style="margin:0 0 16px;font-size:18px;font-weight:700;color:#111827;">$1</h1>')
|
||||
|
||||
// Bold and italic
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/\*(.+?)\*/g, '<em style="color:#6b7280;">$1</em>')
|
||||
|
||||
// Unordered list items
|
||||
html = html.replace(/^(\s*)[-*] (.+)$/gm, '$1<li style="margin:4px 0;padding-left:4px;">$2</li>')
|
||||
|
||||
// Wrap consecutive <li> in <ul>
|
||||
html = html.replace(/((?:<li[^>]*>.*?<\/li>\s*)+)/g, (match) => {
|
||||
return '<ul style="margin:8px 0;padding-left:20px;list-style-type:disc;">' + match + '</ul>'
|
||||
})
|
||||
|
||||
// Images  — local images become CID attachments, external stay as-is
|
||||
const imageMatches = [...html.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)]
|
||||
for (const match of imageMatches) {
|
||||
const [fullMatch, alt, url] = match
|
||||
let imgTag: string
|
||||
|
||||
if (url.startsWith('/uploads/')) {
|
||||
// Local image: read file and attach as CID
|
||||
const buffer = await readLocalImage(url)
|
||||
if (buffer) {
|
||||
const cid = `img-${randomUUID()}`
|
||||
const ext = path.extname(url).toLowerCase() || '.jpg'
|
||||
attachments.push({ filename: `image${ext}`, content: buffer, cid })
|
||||
imgTag = `<img src="cid:${cid}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
|
||||
} else {
|
||||
// Fallback to absolute URL if file not found
|
||||
imgTag = `<img src="${baseUrl}${url}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
|
||||
}
|
||||
} else {
|
||||
imgTag = `<img src="${url}" alt="${alt}" style="max-width:100%;border-radius:8px;margin:12px 0;" />`
|
||||
}
|
||||
html = html.replace(fullMatch, imgTag)
|
||||
}
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
|
||||
const absoluteUrl = url.startsWith('/') ? `${baseUrl}${url}` : url
|
||||
return `<a href="${absoluteUrl}" style="color:#3b82f6;text-decoration:none;">${text}</a>`
|
||||
})
|
||||
|
||||
// Paragraphs
|
||||
html = html.replace(/\n\n+/g, '</p><p style="margin:0 0 12px;">')
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
html = '<p style="margin:0 0 12px;">' + html + '</p>'
|
||||
html = html.replace(/<p[^>]*>\s*<\/p>/g, '')
|
||||
|
||||
return { html, attachments }
|
||||
}
|
||||
|
||||
export async function getAgentEmailTemplate({ agentName, content, appUrl, userName }: AgentEmailParams): Promise<{ html: string; attachments: EmailAttachment[] }> {
|
||||
const greeting = userName ? `Bonjour ${userName},` : 'Bonjour,'
|
||||
const { html: htmlContent, attachments } = await markdownToEmailHtml(content, appUrl)
|
||||
|
||||
// Extract a preview (first ~150 chars of plain text for subtitle)
|
||||
const plainText = content
|
||||
.replace(/^#{1,3}\s+/gm, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/[-*]\s+/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${agentName}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #374151; background: #f3f4f6; margin: 0; padding: 0; }
|
||||
.wrapper { width: 100%; background: #f3f4f6; padding: 32px 16px; }
|
||||
.container { max-width: 620px; margin: 0 auto; }
|
||||
.card { background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.04); }
|
||||
.card-header { background: linear-gradient(135deg, #1e293b 0%, #334155 100%); padding: 28px 32px; }
|
||||
.card-header h1 { margin: 0; font-size: 20px; font-weight: 700; color: #ffffff; }
|
||||
.card-header .subtitle { margin: 6px 0 0; font-size: 13px; color: #94a3b8; }
|
||||
.card-header .badge { display: inline-block; background: rgba(59,130,246,0.2); color: #93c5fd; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 9999px; margin-top: 10px; letter-spacing: 0.5px; text-transform: uppercase; }
|
||||
.card-body { padding: 28px 32px; font-size: 14px; color: #374151; }
|
||||
.card-footer { padding: 20px 32px; border-top: 1px solid #f1f5f9; text-align: center; background: #fafbfc; }
|
||||
.button { display: inline-block; padding: 12px 28px; background-color: #1e293b; color: #ffffff; text-decoration: none; border-radius: 10px; font-weight: 600; font-size: 14px; letter-spacing: 0.3px; }
|
||||
.button:hover { background-color: #334155; }
|
||||
.footer-text { margin-top: 20px; font-size: 12px; color: #9ca3af; text-align: center; }
|
||||
.footer-text a { color: #64748b; text-decoration: none; }
|
||||
.footer-text a:hover { text-decoration: underline; }
|
||||
.date { font-size: 12px; color: #9ca3af; margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1>${agentName}</h1>
|
||||
<div class="subtitle">${plainText.substring(0, 120)}${plainText.length > 120 ? '...' : ''}</div>
|
||||
<span class="badge">Synthèse automatique</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p style="margin:0 0 8px;color:#6b7280;font-size:13px;">${greeting}</p>
|
||||
<p style="margin:0 0 20px;color:#6b7280;font-size:13px;">Votre agent <strong style="color:#1f2937;">${agentName}</strong> a terminé son exécution. Voici les résultats :</p>
|
||||
<hr style="border:none;border-top:1px solid #f1f5f9;margin:0 0 20px;">
|
||||
${htmlContent}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a href="${appUrl}" class="button">Ouvrir dans Memento</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-text">Cet email a été envoyé par votre agent Memento · <a href="${appUrl}/agents">Gérer mes agents</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return { html, attachments }
|
||||
}
|
||||
@@ -65,10 +65,18 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
|
||||
|
||||
export function getTagsProvider(config?: Record<string, string>): AIProvider {
|
||||
// Check database config first, then environment variables
|
||||
const providerType = (config?.AI_PROVIDER_TAGS || process.env.AI_PROVIDER_TAGS);
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
// If no provider is configured, throw a clear error
|
||||
if (!providerType) {
|
||||
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, custom'
|
||||
@@ -84,10 +92,18 @@ export function getTagsProvider(config?: Record<string, string>): AIProvider {
|
||||
|
||||
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
|
||||
// Check database config first, then environment variables
|
||||
const providerType = (config?.AI_PROVIDER_EMBEDDING || process.env.AI_PROVIDER_EMBEDDING);
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
// If no provider is configured, throw a clear error
|
||||
if (!providerType) {
|
||||
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, custom'
|
||||
@@ -104,3 +120,39 @@ export function getEmbeddingsProvider(config?: Record<string, string>): AIProvid
|
||||
export function getAIProvider(config?: Record<string, string>): AIProvider {
|
||||
return getEmbeddingsProvider(config);
|
||||
}
|
||||
|
||||
export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||
// Check database config first, then environment variables
|
||||
// Fallback cascade: chat -> tags -> embeddings
|
||||
const providerType = (
|
||||
config?.AI_PROVIDER_CHAT ||
|
||||
config?.AI_PROVIDER_TAGS ||
|
||||
config?.AI_PROVIDER_EMBEDDING ||
|
||||
config?.AI_PROVIDER ||
|
||||
process.env.AI_PROVIDER_CHAT ||
|
||||
process.env.AI_PROVIDER_TAGS ||
|
||||
process.env.AI_PROVIDER_EMBEDDING ||
|
||||
process.env.AI_PROVIDER
|
||||
);
|
||||
|
||||
// If no provider is configured, throw a clear error
|
||||
if (!providerType) {
|
||||
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, custom'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
const modelName = (
|
||||
config?.AI_MODEL_CHAT ||
|
||||
process.env.AI_MODEL_CHAT ||
|
||||
config?.AI_MODEL_TAGS ||
|
||||
process.env.AI_MODEL_TAGS ||
|
||||
'granite4:latest'
|
||||
);
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
|
||||
|
||||
export class CustomOpenAIProvider implements AIProvider {
|
||||
private model: any;
|
||||
private embeddingModel: any;
|
||||
private apiKey: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(
|
||||
apiKey: string,
|
||||
@@ -13,13 +15,22 @@ export class CustomOpenAIProvider implements AIProvider {
|
||||
modelName: string = 'gpt-4o-mini',
|
||||
embeddingModelName: string = 'text-embedding-3-small'
|
||||
) {
|
||||
this.apiKey = apiKey;
|
||||
this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||
// Create OpenAI-compatible client with custom base URL
|
||||
// Use .chat() to force /chat/completions endpoint (avoids Responses API)
|
||||
const customClient = createOpenAI({
|
||||
baseURL: baseUrl,
|
||||
apiKey: apiKey,
|
||||
fetch: async (url, options) => {
|
||||
const headers = new Headers(options?.headers);
|
||||
headers.set('HTTP-Referer', 'https://localhost:3000');
|
||||
headers.set('X-Title', 'Memento AI');
|
||||
return fetch(url, { ...options, headers });
|
||||
}
|
||||
});
|
||||
|
||||
this.model = customClient(modelName);
|
||||
this.model = customClient.chat(modelName);
|
||||
this.embeddingModel = customClient.embedding(embeddingModelName);
|
||||
}
|
||||
|
||||
@@ -79,7 +90,7 @@ export class CustomOpenAIProvider implements AIProvider {
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
@@ -90,4 +101,47 @@ export class CustomOpenAIProvider implements AIProvider {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: any[], systemPrompt?: string): Promise<any> {
|
||||
try {
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
system: systemPrompt,
|
||||
messages: messages,
|
||||
});
|
||||
|
||||
return { text: text.trim() };
|
||||
} catch (e) {
|
||||
console.error('Erreur chat Custom OpenAI:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
||||
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
|
||||
const opts: Record<string, any> = {
|
||||
model: this.model,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
}
|
||||
if (systemPrompt) opts.system = systemPrompt
|
||||
if (messages) opts.messages = messages
|
||||
else if (prompt) opts.prompt = prompt
|
||||
|
||||
const result = await aiGenerateText(opts as any)
|
||||
return {
|
||||
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
||||
text: result.text,
|
||||
steps: result.steps?.map((step: any) => ({
|
||||
text: step.text,
|
||||
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
|
||||
|
||||
export class DeepSeekProvider implements AIProvider {
|
||||
private model: any;
|
||||
@@ -14,7 +14,7 @@ export class DeepSeekProvider implements AIProvider {
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
this.model = deepseek(modelName);
|
||||
this.model = deepseek.chat(modelName);
|
||||
this.embeddingModel = deepseek.embedding(embeddingModelName);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class DeepSeekProvider implements AIProvider {
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
@@ -85,4 +85,47 @@ export class DeepSeekProvider implements AIProvider {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: any[], systemPrompt?: string): Promise<any> {
|
||||
try {
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
system: systemPrompt,
|
||||
messages: messages,
|
||||
});
|
||||
|
||||
return { text: text.trim() };
|
||||
} catch (e) {
|
||||
console.error('Erreur chat DeepSeek:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
||||
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
|
||||
const opts: Record<string, any> = {
|
||||
model: this.model,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
}
|
||||
if (systemPrompt) opts.system = systemPrompt
|
||||
if (messages) opts.messages = messages
|
||||
else if (prompt) opts.prompt = prompt
|
||||
|
||||
const result = await aiGenerateText(opts as any)
|
||||
return {
|
||||
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
||||
text: result.text,
|
||||
steps: result.steps?.map((step: any) => ({
|
||||
text: step.text,
|
||||
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateText as aiGenerateText, stepCountIs } from 'ai';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
|
||||
|
||||
export class OllamaProvider implements AIProvider {
|
||||
private baseUrl: string;
|
||||
private modelName: string;
|
||||
private embeddingModelName: string;
|
||||
private model: any;
|
||||
|
||||
constructor(baseUrl: string, modelName: string = 'llama3', embeddingModelName?: string) {
|
||||
if (!baseUrl) {
|
||||
@@ -13,6 +16,15 @@ export class OllamaProvider implements AIProvider {
|
||||
this.baseUrl = baseUrl.endsWith('/api') ? baseUrl : `${baseUrl}/api`;
|
||||
this.modelName = modelName;
|
||||
this.embeddingModelName = embeddingModelName || modelName;
|
||||
|
||||
// Create OpenAI-compatible model for streaming support
|
||||
// Ollama exposes /v1/chat/completions which is compatible with the OpenAI SDK
|
||||
const cleanUrl = this.baseUrl.replace(/\/api$/, '');
|
||||
const ollamaClient = createOpenAI({
|
||||
baseURL: `${cleanUrl}/v1`,
|
||||
apiKey: 'ollama',
|
||||
});
|
||||
this.model = ollamaClient.chat(modelName);
|
||||
}
|
||||
|
||||
async generateTags(content: string, language: string = "en"): Promise<TagSuggestion[]> {
|
||||
@@ -148,4 +160,63 @@ Note content: "${content}"`;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: any[], systemPrompt?: string): Promise<any> {
|
||||
try {
|
||||
const ollamaMessages = messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content
|
||||
}));
|
||||
|
||||
if (systemPrompt) {
|
||||
ollamaMessages.unshift({ role: 'system', content: systemPrompt });
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.modelName,
|
||||
messages: ollamaMessages,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
|
||||
|
||||
const data = await response.json();
|
||||
return { text: data.message?.content?.trim() || '' };
|
||||
} catch (e) {
|
||||
console.error('Erreur chat Ollama:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
||||
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
|
||||
const opts: Record<string, any> = {
|
||||
model: this.model,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
}
|
||||
if (systemPrompt) opts.system = systemPrompt
|
||||
if (messages) opts.messages = messages
|
||||
else if (prompt) opts.prompt = prompt
|
||||
|
||||
const result = await aiGenerateText(opts as any)
|
||||
return {
|
||||
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
||||
text: result.text,
|
||||
steps: result.steps?.map((step: any) => ({
|
||||
text: step.text,
|
||||
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
|
||||
|
||||
export class OpenAIProvider implements AIProvider {
|
||||
private model: any;
|
||||
@@ -9,11 +9,12 @@ export class OpenAIProvider implements AIProvider {
|
||||
|
||||
constructor(apiKey: string, modelName: string = 'gpt-4o-mini', embeddingModelName: string = 'text-embedding-3-small') {
|
||||
// Create OpenAI client with API key
|
||||
// Use .chat() to force /chat/completions endpoint (avoids Responses API)
|
||||
const openaiClient = createOpenAI({
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
this.model = openaiClient(modelName);
|
||||
this.model = openaiClient.chat(modelName);
|
||||
this.embeddingModel = openaiClient.embedding(embeddingModelName);
|
||||
}
|
||||
|
||||
@@ -73,7 +74,7 @@ export class OpenAIProvider implements AIProvider {
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
@@ -84,4 +85,47 @@ export class OpenAIProvider implements AIProvider {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: any[], systemPrompt?: string): Promise<any> {
|
||||
try {
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
system: systemPrompt,
|
||||
messages: messages,
|
||||
});
|
||||
|
||||
return { text: text.trim() };
|
||||
} catch (e) {
|
||||
console.error('Erreur chat OpenAI:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
||||
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
|
||||
const opts: Record<string, any> = {
|
||||
model: this.model,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
}
|
||||
if (systemPrompt) opts.system = systemPrompt
|
||||
if (messages) opts.messages = messages
|
||||
else if (prompt) opts.prompt = prompt
|
||||
|
||||
const result = await aiGenerateText(opts as any)
|
||||
return {
|
||||
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
||||
text: result.text,
|
||||
steps: result.steps?.map((step: any) => ({
|
||||
text: step.text,
|
||||
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
|
||||
|
||||
export class OpenRouterProvider implements AIProvider {
|
||||
private model: any;
|
||||
@@ -14,7 +14,7 @@ export class OpenRouterProvider implements AIProvider {
|
||||
apiKey: apiKey,
|
||||
});
|
||||
|
||||
this.model = openrouter(modelName);
|
||||
this.model = openrouter.chat(modelName);
|
||||
this.embeddingModel = openrouter.embedding(embeddingModelName);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class OpenRouterProvider implements AIProvider {
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
@@ -85,4 +85,47 @@ export class OpenRouterProvider implements AIProvider {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: any[], systemPrompt?: string): Promise<any> {
|
||||
try {
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
system: systemPrompt,
|
||||
messages: messages,
|
||||
});
|
||||
|
||||
return { text: text.trim() };
|
||||
} catch (e) {
|
||||
console.error('Erreur chat OpenRouter:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
||||
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options
|
||||
const opts: Record<string, any> = {
|
||||
model: this.model,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
}
|
||||
if (systemPrompt) opts.system = systemPrompt
|
||||
if (messages) opts.messages = messages
|
||||
else if (prompt) opts.prompt = prompt
|
||||
|
||||
const result = await aiGenerateText(opts as any)
|
||||
return {
|
||||
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
||||
text: result.text,
|
||||
steps: result.steps?.map((step: any) => ({
|
||||
text: step.text,
|
||||
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || []
|
||||
})) || []
|
||||
}
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
1106
keep-notes/lib/ai/services/agent-executor.service.ts
Normal file
1106
keep-notes/lib/ai/services/agent-executor.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,7 @@ export class AutoLabelCreationService {
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
trashedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -471,7 +472,7 @@ Deine Antwort (nur JSON):
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
labels: names as any,
|
||||
labels: JSON.stringify(names),
|
||||
labelRelations: {
|
||||
connect: { id: label.id },
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export class BatchOrganizationService {
|
||||
where: {
|
||||
userId,
|
||||
notebookId: null,
|
||||
trashedAt: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user