refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client

This commit is contained in:
Sepehr Ramezani
2026-04-19 19:21:27 +02:00
parent 5296c4da2c
commit 25529a24b8
2476 changed files with 127934 additions and 101962 deletions

View 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>
)
}

View 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>
)
}

View 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 (480767px) : 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 (7681023px) ────────────────────────── */
@media (min-width: 768px) and (max-width: 1023px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
}
/* ─── Desktop (10241279px) ─────────────────────── */
@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;
}
}

View 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>
);
}

View 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>
)
})

View 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>
)
}

View 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
View 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"
}
}
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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) {

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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(),

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -20,6 +20,7 @@ export default async function AppearanceSettingsPage() {
initialFontSize={aiSettings.fontSize}
initialTheme={userSettings.theme}
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
initialCardSizeMode={userSettings.cardSizeMode}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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) {

View 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
}

View 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 }
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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) {

View 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 })
}
}

View 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,
},
})
}

View File

@@ -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,

View 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 });
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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 />

View File

@@ -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
)}
>

View 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>
)
}

View 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">&#9656;</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>
)
}

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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
)}

View File

@@ -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>

View 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
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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}

View 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
}
}

View 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>
)
}

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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]}

View File

@@ -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 (480767px) : 2 colonnes ─────── */
/* ─── Small tablet (480767px) ───────────────────── */
@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 (7681023px) : 23 colonnes ────────── */
/* ─── Tablet (7681023px) ────────────────────────── */
@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 (10241279px) : 34 colonnes ──────── */
/* ─── Desktop (10241279px) ─────────────────────── */
@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+): 45 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;
}
}
}

View File

@@ -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>
)}

View File

@@ -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'))

View File

@@ -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>

View File

@@ -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>
)
})

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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[]>([])

View File

@@ -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')}

View File

@@ -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()

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View 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,
}

View 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 }

View File

@@ -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

View 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);

View 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'
}

View 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 ![alt](url) — 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 &middot; <a href="${appUrl}/agents">Gérer mes agents</a></p>
</div>
</div>
</body>
</html>`
return { html, attachments }
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 })) || []
})) || []
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 },
},

View File

@@ -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