perf: optimize MCP server (O(1) auth, compact JSON, trashedAt fix) + memento-note performance (lazy loading, server-side filtering, XSS fixes, dead code removal, security hardening)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m35s

MCP Server:
- Fix validateApiKey: O(1) direct lookup by shortId instead of loading all keys
- Add trashedAt:null filter to ALL note queries (trashed notes leaked in results)
- Compact JSON output (~40% smaller responses)
- Bounded session cache (Map with MAX_SESSIONS=500) to prevent memory leaks
- PostgreSQL connection pooling (connection_limit=10)
- Rewrite all 22 tool descriptions in clear English
- Fix /sse fallback to proper 307 redirect

memento-note Performance:
- loading=lazy on all note images
- Split notebooksRefreshKey from global refreshKey (note CRUD no longer re-fetches notebooks)
- Remove searchKey from trash count deps (no re-fetch on every keystroke)
- Server-side notebookId filter in getAllNotes() (biggest win)
- Skip collaborator fetch for non-shared notes (eliminates N+1 API calls)
- next/dynamic for MarkdownContent + 4 modals (code-split remark/rehype/KaTeX)
- Memoize DOMPurify sanitize with useMemo

Security:
- XSS: DOMPurify sanitize in note-card and note-history-modal
- Auth anti-enumeration: uniform errors in auth.ts
- CRON_SECRET mandatory on cron endpoints
- Rate limiting on login (5 attempts/min per email)
- Centralized API auth helpers (requireAuth/requireAdmin)
- randomize-labels changed GET→POST
- Removed debug endpoints (/api/debug/config, /api/debug/test-chat)

Cleanup:
- Removed dead code: .backup-keep, settings-backup, fix-*.js, debug-theme, fix-labels route
- Removed sensitive console.error in auth.ts
- Ollama fetchWithTimeout (30s/60s AbortController)
- i18n: full Arabic translation, Farsi missing keys
- Masonry drag-and-drop fix (localOrderMap, cross-section block)
- Sidebar notebook tooltip on truncation
This commit is contained in:
Antigravity
2026-05-03 18:41:38 +00:00
parent aee4b17306
commit 718f4c6900
58 changed files with 13249 additions and 7474 deletions

View File

@@ -1,85 +0,0 @@
'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

@@ -1,454 +0,0 @@
'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

@@ -1,132 +0,0 @@
/**
* 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

@@ -1,292 +0,0 @@
'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

@@ -1,677 +0,0 @@
'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

@@ -1,44 +0,0 @@
'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

@@ -1375,28 +1375,35 @@ export async function syncAllEmbeddings() {
}
// Get all notes including those shared with the user
export async function getAllNotes(includeArchived = false) {
export async function getAllNotes(includeArchived = false, notebookId?: string) {
const session = await auth();
if (!session?.user?.id) return [];
const userId = session.user.id;
try {
// Fetch own notes + shared notes in parallel — no embedding to keep transfer fast
const [ownNotes, acceptedShares] = await Promise.all([
prisma.note.findMany({
where: {
userId,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
},
select: NOTE_LIST_SELECT,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
}),
const whereClause: any = {
userId,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
...(notebookId !== undefined ? { notebookId: notebookId || null } : {}),
}
const ownNotes = await prisma.note.findMany({
where: whereClause,
select: NOTE_LIST_SELECT,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
if (notebookId) {
return ownNotes.map(parseNote)
}
const [acceptedShares] = await Promise.all([
prisma.noteShare.findMany({
where: { userId, status: 'accepted' },
include: { note: { select: NOTE_LIST_SELECT } }

View File

@@ -5,7 +5,7 @@ import { auth } from '@/auth';
export const dynamic = 'force-dynamic';
export async function GET() {
export async function POST() {
try {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {

View File

@@ -14,13 +14,14 @@ export const dynamic = 'force-dynamic'
* Authorization: Bearer <CRON_SECRET>
*/
export async function POST(request: NextRequest) {
// Optional auth
const cronSecret = process.env.CRON_SECRET
if (cronSecret) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!cronSecret) {
console.error('[CronAgents] CRON_SECRET env var is required but not set')
return NextResponse.json({ error: 'Server misconfiguration' }, { status: 500 })
}
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {

View File

@@ -4,14 +4,14 @@ import prisma from '@/lib/prisma';
export const dynamic = 'force-dynamic'; // No caching
export async function POST(request: NextRequest) {
// Optional auth: set CRON_SECRET env var, callers must pass
// Authorization: Bearer <CRON_SECRET>
const cronSecret = process.env.CRON_SECRET
if (cronSecret) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!cronSecret) {
console.error('[CronReminders] CRON_SECRET env var is required but not set')
return NextResponse.json({ error: 'Server misconfiguration' }, { status: 500 })
}
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {

View File

@@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
// Visible directement dans `docker logs memento-web`
console.error('[CLIENT-ERROR-REPORT]', JSON.stringify(body, null, 2))
} catch {
// ignore
// ignore malformed payload
}
return NextResponse.json({ ok: true })
}

View File

@@ -1,41 +0,0 @@
import { NextResponse } from 'next/server';
import { getSystemConfig } from '@/lib/config';
import { auth } from '@/auth';
/**
* Debug endpoint to check AI configuration
* This helps verify that OpenAI is properly configured
*/
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const config = await getSystemConfig();
// Return only AI-related config for debugging
const aiConfig = {
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
OPENAI_API_KEY: config.OPENAI_API_KEY ? 'set (hidden)' : 'not set',
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set',
OLLAMA_MODEL: config.OLLAMA_MODEL || 'not set',
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || 'not set',
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? 'set (hidden)' : 'not set',
};
return NextResponse.json(aiConfig);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to get config', details: error },
{ status: 500 }
);
}
}

View File

@@ -1,38 +0,0 @@
import { NextResponse } from 'next/server';
import { chatService } from '@/lib/ai/services/chat.service';
import { auth } from '@/auth';
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
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

@@ -1,127 +0,0 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { auth } from '@/auth'
function getHashColor(name: string): string {
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
try {
const result = { created: 0, deleted: 0, missing: [] as string[] }
// Get ALL users
const users = await prisma.user.findMany({
select: { id: true, email: true }
})
for (const user of users) {
const userId = user.id
// 1. Get all labels from notes
const allNotes = await prisma.note.findMany({
where: { userId },
select: { labels: true }
})
const labelsInNotes = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
if (Array.isArray(parsed)) {
parsed.forEach(l => {
if (l && l.trim()) labelsInNotes.add(l.trim())
})
}
} catch (e) {}
}
})
// 2. Get existing Label records
const existingLabels = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true }
})
const existingLabelMap = new Map<string, any>()
existingLabels.forEach(label => {
existingLabelMap.set(label.name.toLowerCase(), label)
})
// 3. Create missing Label records
for (const labelName of labelsInNotes) {
if (!existingLabelMap.has(labelName.toLowerCase())) {
try {
await prisma.label.create({
data: {
userId,
name: labelName,
color: getHashColor(labelName)
}
})
result.created++
} catch (e: any) {
console.error(`[FIX] ✗ Failed to create "${labelName}":`, e.message, e.code)
result.missing.push(labelName)
}
}
}
// 4. Delete orphan Label records
const usedLabelsSet = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : []
if (Array.isArray(parsed)) {
parsed.forEach(l => usedLabelsSet.add(l.toLowerCase()))
}
} catch (e) {}
}
})
for (const label of existingLabels) {
if (!usedLabelsSet.has(label.name.toLowerCase())) {
try {
await prisma.label.delete({
where: { id: label.id }
})
result.deleted++
} catch (e) {}
}
}
}
revalidatePath('/')
return NextResponse.json({
success: true,
...result,
message: `Created ${result.created} labels, deleted ${result.deleted} orphans`
})
} catch (error) {
console.error('[FIX] Error:', error)
return NextResponse.json(
{ success: false, error: String(error) },
{ status: 500 }
)
}
}

View File

@@ -4,6 +4,7 @@ import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import prisma from '@/lib/prisma';
import bcrypt from 'bcryptjs';
import { rateLimit } from '@/lib/rate-limit';
export const { auth, signIn, signOut, handlers } = NextAuth({
...authConfig,
@@ -16,17 +17,21 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
.safeParse(credentials);
if (!parsedCredentials.success) {
console.error('Invalid credentials format');
return null;
}
const { email, password } = parsedCredentials.data;
const { allowed } = rateLimit(`login:${email.toLowerCase()}`)
if (!allowed) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() }
});
if (!user || !user.password) {
console.error('User not found or no password set');
return null;
}
@@ -41,10 +46,8 @@ export const { auth, signIn, signOut, handlers } = NextAuth({
};
}
console.error('Password mismatch');
return null;
} catch (error) {
console.error('CRITICAL AUTH ERROR:', error);
} catch {
return null;
}
},

View File

@@ -1,9 +0,0 @@
'use client'
export function DebugTheme({ theme }: { theme: string }) {
return (
<div className="fixed bottom-4 left-4 z-50 bg-black text-white p-2 rounded text-xs opacity-80 pointer-events-none">
Debug Theme: {theme}
</div>
)
}

View File

@@ -14,7 +14,9 @@ export function ErrorReporter() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch(() => {})
}).catch(() => {
console.error('[ErrorReporter] Failed to report client error (endpoint removed)')
})
}
function onError(event: ErrorEvent) {

View File

@@ -237,13 +237,9 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
}
let allNotes = search
? await searchNotes(search, semanticMode, notebook || undefined)
: await getAllNotes()
: await getAllNotes(false, notebook || undefined)
// 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 && !note._isShared)
} else {
if (!notebook && !search) {
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
}

View File

@@ -185,7 +185,16 @@ export function MasonryGrid({
if (notes !== prevNotesRef.current) {
const localSizeMap = new Map(localNotes.map(n => [n.id, n.size]));
const localOrderMap = new Map(localNotes.map((n, i) => [n.id, i]));
const newLocalNotes = notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }));
newLocalNotes.sort((a, b) => {
const oA = localOrderMap.get(a.id)
const oB = localOrderMap.get(b.id)
if (oA !== undefined && oB !== undefined) return oA - oB
if (oA !== undefined) return -1
if (oB !== undefined) return 1
return 0
})
setLocalNotes(newLocalNotes);
prevNotesRef.current = notes;
}
@@ -239,16 +248,20 @@ export function MasonryGrid({
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),
);
const current = localNotesRef.current
const activeIdx = current.findIndex(n => n.id === active.id)
const overIdx = current.findIndex(n => n.id === over.id)
if (activeIdx === -1 || overIdx === -1) return
const activeNote = current[activeIdx]
const overNote = current[overIdx]
if (activeNote.isPinned !== overNote.isPinned) return
const reordered = arrayMove(current, activeIdx, overIdx);
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);

View File

@@ -21,7 +21,8 @@ import {
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, AlignLeft, FileCode2, PenLine, ListChecks } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useState, useEffect, useTransition, useOptimistic, memo, useMemo } from 'react'
import dynamic from 'next/dynamic'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote, restoreNote, permanentDeleteNote } from '@/app/actions/notes'
@@ -42,17 +43,20 @@ 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 { FusionModal } from './fusion-modal'
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
loading: () => <div className="text-sm text-muted-foreground animate-pulse"></div>,
})
const CollaboratorDialog = dynamic(() => import('./collaborator-dialog').then(m => ({ default: m.CollaboratorDialog })), { ssr: false })
const ConnectionsOverlay = dynamic(() => import('./connections-overlay').then(m => ({ default: m.ConnectionsOverlay })), { ssr: false })
const ComparisonModal = dynamic(() => import('./comparison-modal').then(m => ({ default: m.ComparisonModal })), { ssr: false })
const FusionModal = dynamic(() => import('./fusion-modal').then(m => ({ default: m.FusionModal })), { ssr: false })
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
@@ -186,6 +190,14 @@ export const NoteCard = memo(function NoteCard({
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
const [reminderDate, setReminderDate] = useState<Date | null>(note.reminder ? new Date(note.reminder) : null)
const sanitizedHtml = useMemo(() => {
if (note.type !== 'richtext' || !note.content) return ''
if (typeof window !== 'undefined') {
return require('isomorphic-dompurify').sanitize(note.content)
}
return note.content
}, [note.type, note.content])
const handleUpdateReminder = async (noteId: string, reminder: Date | null) => {
startTransition(async () => {
try {
@@ -257,6 +269,8 @@ export const NoteCard = memo(function NoteCard({
return
}
if (!isSharedNote) return
let isMounted = true
const loadCollaborators = async () => {
@@ -270,7 +284,6 @@ export const NoteCard = memo(function NoteCard({
}
}
} catch (error) {
console.error('Failed to load collaborators:', error)
if (isMounted) {
setCollaborators([])
}
@@ -633,7 +646,7 @@ export const NoteCard = memo(function NoteCard({
onToggleItem={handleCheckItem}
/>
) : note.type === 'richtext' ? (
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: note.content || '' }} />
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
) : (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent

View File

@@ -81,7 +81,7 @@ function VersionPreview({ entry, language }: { entry: NoteHistoryEntry; language
<MarkdownContent content={entry.content || ''} />
</div>
) : isRt ? (
<div dangerouslySetInnerHTML={{ __html: entry.content || '' }} />
<div dangerouslySetInnerHTML={{ __html: require('isomorphic-dompurify').sanitize(entry.content || '') }} />
) : (
<pre className="whitespace-pre-wrap font-sans">{entry.content || ''}</pre>
)}

View File

@@ -18,6 +18,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
<img
src={images[0]}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
) : images.length === 2 ? (
@@ -27,6 +28,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
key={idx}
src={img}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
))}
@@ -36,6 +38,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
<img
src={images[0]}
alt=""
loading="lazy"
className="col-span-2 w-full h-auto rounded-lg"
/>
{images.slice(1).map((img, idx) => (
@@ -43,6 +46,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
key={idx}
src={img}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
))}
@@ -54,6 +58,7 @@ export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
key={idx}
src={img}
alt=""
loading="lazy"
className="w-full h-auto rounded-lg"
/>
))}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback } from 'react'
import { useState, useCallback, useRef } from 'react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
@@ -18,6 +18,36 @@ import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from '@/components/label-management-dialog'
import { Notebook } from '@/lib/types'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
function NotebookNameTooltip({ name, children }: { name: string; children: React.ReactNode }) {
const spanRef = useRef<HTMLSpanElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
const checkTruncation = useCallback((node: HTMLSpanElement | null) => {
if (!node) return
setIsTruncated(node.scrollWidth > node.clientWidth)
}, [])
return (
<Tooltip open={isTruncated ? undefined : false}>
<TooltipTrigger asChild>
<span
ref={(el) => { spanRef.current = el; checkTruncation(el) }}
onMouseEnter={() => checkTruncation(spanRef.current)}
className="truncate min-w-0"
>
{children}
</span>
</TooltipTrigger>
{isTruncated && (
<TooltipContent side="right" className="max-w-[240px] break-words text-sm">
{name}
</TooltipContent>
)}
</Tooltip>
)
}
export function NotebooksList() {
const pathname = usePathname()
@@ -152,12 +182,14 @@ export function NotebooksList() {
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
/>
<span
className={cn("text-[15px] font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
</span>
<NotebookNameTooltip name={notebook.name}>
<span
className={cn("text-[15px] font-medium tracking-wide", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
</span>
</NotebookNameTooltip>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Actions menu for active notebook */}
@@ -241,7 +273,11 @@ export function NotebooksList() {
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-[15px] font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
<NotebookNameTooltip name={notebook.name}>
<span className="text-[15px] font-medium tracking-wide text-start">
{notebook.name}
</span>
</NotebookNameTooltip>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}

View File

@@ -1,87 +0,0 @@
'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

@@ -1,86 +0,0 @@
'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

@@ -1,73 +0,0 @@
'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

@@ -1,97 +0,0 @@
'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

@@ -1,105 +0,0 @@
'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

@@ -1,36 +0,0 @@
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

@@ -1,6 +0,0 @@
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

@@ -46,7 +46,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
useEffect(() => {
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return
getTrashCount().then(setTrashCount)
}, [pathname, searchKey, refreshKey])
}, [pathname, refreshKey])
// Hide sidebar on Agents, Chat IA and Lab routes
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null

View File

@@ -5,18 +5,25 @@ import { createContext, useContext, useState, useCallback, useMemo } from 'react
interface NoteRefreshContextType {
refreshKey: number
triggerRefresh: () => void
notebooksRefreshKey: number
triggerNotebooksRefresh: () => void
}
const NoteRefreshContext = createContext<NoteRefreshContextType | undefined>(undefined)
export function NoteRefreshProvider({ children }: { children: React.ReactNode }) {
const [refreshKey, setRefreshKey] = useState(0)
const [notebooksRefreshKey, setNotebooksRefreshKey] = useState(0)
const triggerRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1)
}, [])
const value = useMemo(() => ({ refreshKey, triggerRefresh }), [refreshKey, triggerRefresh])
const triggerNotebooksRefresh = useCallback(() => {
setNotebooksRefreshKey(prev => prev + 1)
}, [])
const value = useMemo(() => ({ refreshKey, triggerRefresh, notebooksRefreshKey, triggerNotebooksRefresh }), [refreshKey, triggerRefresh, notebooksRefreshKey, triggerNotebooksRefresh])
return (
<NoteRefreshContext.Provider value={value}>
@@ -33,11 +40,7 @@ export function useNoteRefresh() {
return context
}
/**
* Same as useNoteRefresh but tolerates being called outside the provider
* (e.g. shared header rendered in admin pages). Returns a no-op when absent.
*/
export function useNoteRefreshOptional(): NoteRefreshContextType {
const context = useContext(NoteRefreshContext)
return context ?? { refreshKey: 0, triggerRefresh: () => {} }
return context ?? { refreshKey: 0, triggerRefresh: () => {}, notebooksRefreshKey: 0, triggerNotebooksRefresh: () => {} }
}

View File

@@ -84,7 +84,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
const [isLoading, setIsLoading] = useState(true)
const [isMovingNote, setIsMovingNote] = useState(false)
const [error, setError] = useState<string | null>(null)
const { triggerRefresh, refreshKey } = useNoteRefresh()
const { triggerRefresh, triggerNotebooksRefresh, notebooksRefreshKey } = useNoteRefresh()
// ===== DERIVED STATE =====
const currentLabels = useMemo(() => {
@@ -115,10 +115,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
loadNotebooks()
}, [loadNotebooks])
// Recharge les carnets à chaque fois qu'une note est modifiée/supprimée
useEffect(() => {
if (refreshKey > 0) loadNotebooks()
}, [refreshKey, loadNotebooks])
if (notebooksRefreshKey > 0) loadNotebooks()
}, [notebooksRefreshKey, loadNotebooks])
// ===== ACTIONS: NOTEBOOKS =====
const createNotebookOptimistic = useCallback(async (data: CreateNotebookInput) => {
@@ -134,8 +133,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
// Reload notebooks from server to update sidebar state
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
const updateNotebook = useCallback(async (notebookId: string, data: UpdateNotebookInput) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -149,8 +149,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
const deleteNotebook = useCallback(async (notebookId: string) => {
const response = await fetch(`/api/notebooks/${notebookId}`, {
@@ -162,8 +163,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
const updateNotebookOrderOptimistic = useCallback(async (notebookIds: string[]) => {
const response = await fetch('/api/notebooks/reorder', {
@@ -177,8 +179,9 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
}, [loadNotebooks, triggerNotebooksRefresh, triggerRefresh])
// ===== ACTIONS: LABELS =====
const createLabel = useCallback(async (data: CreateLabelInput) => {
@@ -233,6 +236,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
}
await loadNotebooks()
triggerNotebooksRefresh()
triggerRefresh()
} catch (error) {
toast.error('Failed to move note. Please try again.')

View File

@@ -1,178 +0,0 @@
const fs = require('fs');
const file = 'app/(admin)/admin/settings/admin-settings-form.tsx';
let content = fs.readFileSync(file, 'utf-8');
// 1. Add icons
content = content.replace(
"import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'",
"import { TestTube, ExternalLink, RefreshCw, Shield, Brain, Mail, Wrench } from 'lucide-react'"
);
// 2. Change root wrapper to 2 columns
content = content.replace(
'<div className="space-y-6">',
`<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Left Column */}
<div className="flex flex-col gap-6">`
);
// 3. Close left column and open right column right before AI Providers
content = content.replace(
' <Card>\n <CardHeader>\n <CardTitle>{t(\'admin.ai.title\')}</CardTitle>',
` </div>
{/* Right Column */}
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>{t('admin.ai.title')}</CardTitle>`
);
// 4. Move Email from after AI to Left Column? No, let's keep the order and just split them.
// Wait, in my previous attempt I put Security + AI on left, and Email + Tools on right?
// Let's just do that to be safe.
// Let's put Security + AI in Left Column, and Email + Tools in Right Column.
// So:
// Left Column ends after AI Providers.
// Right Column starts before Email.
content = content.replace(
' <Card>\n <CardHeader>\n <CardTitle>{t(\'admin.email.title\')}</CardTitle>',
` </div>
{/* Right Column */}
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>{t('admin.email.title')}</CardTitle>`
);
// Add the closing div for Right Column at the very end
content = content.replace(
' </div>\n )\n}\n',
' </div>\n </div>\n )\n}\n'
);
// Now let's replace all Card components with the custom design system
// Security Card
content = content.replace(
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.security.title\')}</CardTitle>\n <CardDescription>{t(\'admin.security.description\')}</CardDescription>\n </CardHeader>',
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Shield className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.security.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.security.description')}</p>
</div>
</div>`
);
content = content.replace(/<CardContent/g, '<div');
content = content.replace(/<\/CardContent>/g, '</div>');
content = content.replace(/<CardFooter>/g, '<div className="px-6 pb-6">');
content = content.replace(/<\/CardFooter>/g, '</div>');
content = content.replace(
'</form>\n </Card>',
'</form>\n </div>'
); // do it 4 times
content = content.replace('</form>\n </Card>', '</form>\n </div>');
content = content.replace('</form>\n </Card>', '</form>\n </div>');
content = content.replace('</form>\n </Card>', '</form>\n </div>');
// AI Card
content = content.replace(
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.ai.title\')}</CardTitle>\n <CardDescription>{t(\'admin.ai.description\')}</CardDescription>\n </CardHeader>',
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Brain className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.ai.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.ai.description')}</p>
</div>
</div>`
);
// Email Card
content = content.replace(
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.email.title\')}</CardTitle>\n <CardDescription>{t(\'admin.email.description\')}</CardDescription>\n </CardHeader>',
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Mail className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.email.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.email.description')}</p>
</div>
</div>`
);
// Tools Card
content = content.replace(
'<Card>\n <CardHeader>\n <CardTitle>{t(\'admin.tools.title\')}</CardTitle>\n <CardDescription>{t(\'admin.tools.description\')}</CardDescription>\n </CardHeader>',
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Wrench className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('admin.tools.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.tools.description')}</p>
</div>
</div>`
);
// Replace AI block styling
content = content.replace(
'p-4 border rounded-lg bg-primary/5 dark:bg-primary/10',
'p-4 border border-border/50 rounded-lg bg-muted/50'
);
content = content.replace(
'p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20',
'p-4 border border-border/50 rounded-lg bg-muted/50 mt-4'
);
content = content.replace(
'p-4 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20',
'p-4 border border-border/50 rounded-lg bg-muted/50 mt-4'
);
// Also replace dark text colors inside headers
content = content.replace(
'<h3 className="text-base font-semibold flex items-center gap-2">\\n <span className="text-primary">🏷️</span>',
'<h3 className="text-base font-semibold flex items-center gap-2 text-foreground">\\n <span className="text-primary">🏷️</span>'
);
content = content.replace(
'<h3 className="text-base font-semibold flex items-center gap-2">\\n <span className="text-green-600">🔍</span>',
'<h3 className="text-base font-semibold flex items-center gap-2 text-foreground">\\n <span className="text-green-600">🔍</span>'
);
content = content.replace(
'<h3 className="text-base font-semibold flex items-center gap-2">\\n <span className="text-blue-600">💬</span>',
'<h3 className="text-base font-semibold flex items-center gap-2 text-foreground">\\n <span className="text-blue-600">💬</span>'
);
// Email / Search test result classes
content = content.replace(
/border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950\/30 dark:text-green-300/g,
'border-green-500/20 bg-green-500/10 text-green-600'
);
content = content.replace(
/border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950\/30 dark:text-red-300/g,
'border-red-500/20 bg-red-500/10 text-red-600'
);
// CardFooter specific layout fixes since it became a div
content = content.replace(
'<div className="flex justify-between pb-6">',
'<div className="px-6 pb-6 flex justify-between">'
); // Might not match exact, let's fix manually if needed.
// Wait, the CardFooter replacement was: content.replace(/<CardFooter>/g, '<div className="px-6 pb-6">');
// Then there was <CardFooter className="flex justify-between"> which became <div className="flex justify-between">... Need to make sure it has padding.
content = content.replace(
'<div className="flex justify-between">',
'<div className="px-6 pb-6 flex justify-between">'
);
fs.writeFileSync(file, content);
console.log("Success");

View File

@@ -1,11 +0,0 @@
const fs = require('fs');
const file = 'app/(admin)/admin/settings/admin-settings-form.tsx';
let content = fs.readFileSync(file, 'utf-8');
// Replace any leftover <CardFooter> tags
content = content.replace(/<CardFooter>/g, '<div className="px-6 pb-6">');
content = content.replace(/<CardFooter className="([^"]+)">/g, '<div className="px-6 pb-6 $1">');
content = content.replace(/<\/CardFooter>/g, '</div>');
fs.writeFileSync(file, content);
console.log("Fixed CardFooters");

View File

@@ -1,192 +0,0 @@
const fs = require('fs');
const file = 'components/mcp/mcp-settings-panel.tsx';
let content = fs.readFileSync(file, 'utf-8');
// Replace standard <Card className="p-6"> with our styled headers
// Section 1
content = content.replace(
`<Card className="p-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>`,
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center text-blue-500 shrink-0">
<Info className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('mcpSettings.whatIsMcp.title')}</h2>
</div>
</div>
<div className="p-6 pt-4">`
);
// We need to close the p-6 div, wait, the <Card> ends with </Card>. So we need to replace </Card> carefully.
// Let's do it section by section or just globally replace </Card> with </div></div> since we added a new wrapping div.
// For Section 1, the end is </Card>
content = content.replace(
` <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</Card>`,
` <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>`
); // The original has </div></div></Card>
// Section 2
content = content.replace(
`<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<Server className="h-5 w-5 shrink-0" />
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
</div>`,
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Server className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('mcpSettings.serverStatus.title')}</h2>
</div>
</div>
<div className="p-6">`
);
content = content.replace(
` </div>
)}
</div>
</Card>`,
` </div>
)}
</div>
</div>
</div>`
);
// Section 3
content = content.replace(
`<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
<p className="text-sm text-gray-500">
{t('mcpSettings.apiKeys.description')}
</p>
</div>
</div>`,
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<Key className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">{t('mcpSettings.apiKeys.title')}</h2>
<p className="text-sm text-muted-foreground">
{t('mcpSettings.apiKeys.description')}
</p>
</div>
</div>`
);
content = content.replace(
` ))}
</div>
)}
</Card>`,
` ))}
</div>
)}
</div>`
);
// Wait! I forgot to add `<div className="p-6">` after the header for Section 3!
// The header ended at <Dialog open...
content = content.replace(
` <CreateKeyDialog
onGenerate={handleGenerate}
isPending={isPending}
/>
</Dialog>
</div>`,
` <CreateKeyDialog
onGenerate={handleGenerate}
isPending={isPending}
/>
</Dialog>
</div>
<div className="p-6">`
);
// Section 4
// Wait, Section 4 is a subcomponent! <ConfigInstructions>
content = content.replace(
`<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<ExternalLink className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">
{t('mcpSettings.configInstructions.title')}
</h2>
<p className="text-sm text-gray-500">
{t('mcpSettings.configInstructions.description')}
</p>
</div>
</div>`,
`<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
<div className="flex items-center gap-3 p-6 border-b border-border">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
<ExternalLink className="h-5 w-5" />
</div>
<div>
<h2 className="font-semibold text-foreground">
{t('mcpSettings.configInstructions.title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('mcpSettings.configInstructions.description')}
</p>
</div>
</div>
<div className="p-6 space-y-2">`
);
content = content.replace(
` ))}
</div>
</Card>`,
` ))}
</div>
</div>`
);
// In Section 4, remove `<div className="space-y-2">` because I merged it into `<div className="p-6 space-y-2">`
content = content.replace(
`<div className="p-6 space-y-2">\n <div className="space-y-2">`,
`<div className="p-6 space-y-2">`
);
// Fix colors
content = content.replace(/text-gray-500/g, 'text-muted-foreground');
content = content.replace(/text-gray-600/g, 'text-muted-foreground');
content = content.replace(/text-gray-400/g, 'text-muted-foreground');
content = content.replace(/bg-gray-100 dark:bg-gray-800/g, 'bg-muted');
content = content.replace(/bg-gray-50 dark:bg-gray-900/g, 'bg-muted/50');
content = content.replace(/bg-gray-50/g, 'bg-muted/50');
content = content.replace(/dark:hover:bg-gray-900/g, 'hover:bg-muted');
// Remove Card import
content = content.replace("import { Card } from '@/components/ui/card'", "");
// Grid layout for mcp-settings-panel root
content = content.replace(
'<div className="space-y-6">',
'<div className="columns-1 lg:columns-2 gap-6 space-y-6">'
);
fs.writeFileSync(file, content);
console.log("Done");

View File

@@ -27,6 +27,16 @@ export class OllamaProvider implements AIProvider {
this.model = ollamaClient.chat(modelName);
}
private async fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number = 30_000): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, { ...options, signal: controller.signal })
} finally {
clearTimeout(timer)
}
}
async generateTags(content: string, language: string = "en"): Promise<TagSuggestion[]> {
try {
const promptText = language === 'fa'
@@ -55,7 +65,7 @@ Rules:
Respond ONLY as a JSON list of objects: [{"tag": "string", "confidence": number}].
Note content: "${content}"`;
const response = await fetch(`${this.baseUrl}/generate`, {
const response = await this.fetchWithTimeout(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -75,7 +85,6 @@ Note content: "${content}"`;
return JSON.parse(jsonMatch[0]);
}
// Support for { "tags": [...] } format
const objectMatch = text.match(/\{\s*"tags"\s*:\s*(\[[\s\S]*\])\s*\}/);
if (objectMatch && objectMatch[1]) {
return JSON.parse(objectMatch[1]);
@@ -90,14 +99,14 @@ Note content: "${content}"`;
async getEmbeddings(text: string): Promise<number[]> {
try {
const response = await fetch(`${this.baseUrl}/embeddings`, {
const response = await this.fetchWithTimeout(`${this.baseUrl}/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.embeddingModelName,
prompt: text,
}),
});
}, 60_000);
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
@@ -111,7 +120,7 @@ Note content: "${content}"`;
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const response = await fetch(`${this.baseUrl}/generate`, {
const response = await this.fetchWithTimeout(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -126,7 +135,6 @@ Note content: "${content}"`;
const data = await response.json();
const text = data.response;
// Extract JSON from response
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
return JSON.parse(jsonMatch[0]);
@@ -141,7 +149,7 @@ Note content: "${content}"`;
async generateText(prompt: string): Promise<string> {
try {
const response = await fetch(`${this.baseUrl}/generate`, {
const response = await this.fetchWithTimeout(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -172,7 +180,7 @@ Note content: "${content}"`;
ollamaMessages.unshift({ role: 'system', content: systemPrompt });
}
const response = await fetch(`${this.baseUrl}/chat`, {
const response = await this.fetchWithTimeout(`${this.baseUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -0,0 +1,19 @@
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export async function requireAuth() {
const session = await auth()
if (!session?.user?.id) {
return { session: null, error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }
}
return { session, error: null }
}
export async function requireAdmin() {
const result = await requireAuth()
if (result.error) return result
if ((result.session!.user as any).role !== 'ADMIN') {
return { session: null, error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }
}
return result
}

View File

@@ -0,0 +1,29 @@
const attempts = new Map<string, { count: number; resetAt: number }>()
const WINDOW_MS = 60_000
const MAX_ATTEMPTS = 5
export function rateLimit(key: string): { allowed: boolean; retryAfterMs: number } {
const now = Date.now()
const entry = attempts.get(key)
if (!entry || now > entry.resetAt) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS })
return { allowed: true, retryAfterMs: 0 }
}
entry.count++
if (entry.count > MAX_ATTEMPTS) {
return { allowed: false, retryAfterMs: entry.resetAt - now }
}
return { allowed: true, retryAfterMs: 0 }
}
setInterval(() => {
const now = Date.now()
for (const [k, v] of attempts) {
if (now > v.resetAt) attempts.delete(k)
}
}, 60_000)

View File

@@ -26,21 +26,21 @@
"sending": "جاري الإرسال...",
"sendResetLink": "إرسال رابط إعادة التعيين",
"backToLogin": "العودة إلى تسجيل الدخول",
"signOut": "Sign out",
"signOut": "تسجيل الخروج",
"confirmPassword": "تأكيد كلمة المرور",
"confirmPasswordPlaceholder": "أعد إدخال كلمة المرور"
},
"sidebar": {
"notes": "Notes",
"reminders": "Reminders",
"labels": "Labels",
"editLabels": "Edit labels",
"notes": "الملاحظات",
"reminders": "التذكيرات",
"labels": "التسميات",
"editLabels": "تعديل التسميات",
"newNoteTabs": "ملاحظة جديدة",
"newNoteTabsHint": "إنشاء ملاحظة في هذا الدفتر",
"noLabelsInNotebook": "لا توجد تسميات في هذا الدفتر",
"archive": "Archive",
"trash": "Trash",
"clearFilter": "Remove filter"
"archive": "الأرشيف",
"trash": "المهملات",
"clearFilter": "إزالة الفلتر"
},
"notes": {
"title": "الملاحظات",
@@ -88,8 +88,8 @@
"itemOrMediaRequired": "الرجاء إضافة عنصر واحد على الأقل أو وسائط",
"noteCreated": "تم إنشاء الملاحظة بنجاح",
"noteCreateFailed": "فشل في إنشاء الملاحظة",
"deleted": "Note deleted",
"deleteFailed": "Failed to delete note",
"deleted": "تم حذف الملاحظة",
"deleteFailed": "فشل حذف الملاحظة",
"aiAssistant": "مساعد الذكاء الاصطناعي",
"changeSize": "تغيير الحجم",
"backgroundOptions": "خيارات الخلفية",
@@ -176,10 +176,10 @@
"history": "السجل",
"historyRestored": "تم استعادة النسخة",
"historyEnabled": "تم تفعيل السجل",
"historyDisabledTitle": "Version history",
"historyDisabledTitle": "سجل النسخ",
"historyDisabledDesc": "السجل معطل لحسابك.",
"historyEnabledTitle": "History enabled!",
"historyEnabledDesc": "Versions of this note will now be recorded.",
"historyEnabledTitle": "تم تفعيل السجل!",
"historyEnabledDesc": "سيتم الآن تسجيل نسخ هذه الملاحظة.",
"enableHistory": "تفعيل السجل",
"historyEmpty": "لا توجد نسخ متاحة",
"historySelectVersion": "اختر نسخة لمعاينة محتواها",
@@ -188,32 +188,37 @@
"sortDateAsc": "التاريخ (الأقدم)",
"sortTitleAsc": "العنوان أ ← ي",
"sortTitleDesc": "العنوان ي ← أ",
"suggestTitle": "AI title",
"generateTitleFromImage": "Generate title from image",
"titleGenerated": "Title generated",
"content": "Content",
"restore": "Restore",
"createFailed": "Failed to create note",
"updateFailed": "Failed to update note",
"archived": "Note archived",
"archiveFailed": "Failed to archive",
"sort": "Sort",
"confirmDeleteTitle": "Delete note",
"leftShare": "Share removed",
"dismissed": "Note dismissed from recent",
"generalNotes": "General Notes",
"suggestTitle": "عنوان بالذكاء الاصطناعي",
"generateTitleFromImage": "إنشاء عنوان من الصورة",
"titleGenerated": "تم إنشاء العنوان",
"content": "المحتوى",
"restore": "استعادة",
"createFailed": "فشل إنشاء الملاحظة",
"updateFailed": "فشل تحديث الملاحظة",
"archived": "تم أرشفة الملاحظة",
"archiveFailed": "فشل الأرشفة",
"sort": "ترتيب",
"confirmDeleteTitle": "حذف الملاحظة",
"leftShare": "تمت إزالة المشاركة",
"dismissed": "تمت إزالة الملاحظة من الحديثة",
"generalNotes": "الملاحظات العامة",
"noteType": "نوع الملاحظة",
"typeText": "نص",
"typeMarkdown": "ماركداون",
"typeRichText": "نص منسق",
"typeChecklist": "قائمة مراجعة",
"convertedToRichText": "Converted to rich text",
"conversionFailed": "Conversion failed, staying in Markdown",
"convertedToRichText": "تم التحويل إلى نص منسق",
"conversionFailed": "فشل التحويل، البقاء في Markdown",
"richTextPlaceholder": "اكتب ملاحظة...",
"switchTypeTitle": "تغيير نوع الملاحظة؟",
"switchTypeWarning": "قد يفقد بعض التنسيق عند التحويل إلى {type}.",
"switchTypeContentPreserved": "سيتم الحفاظ على المحتوى كنص عادي.",
"switchType": "تحويل إلى {type}"
"switchType": "تحويل إلى {type}",
"deleteVersionDesc": "لا يمكن التراجع عن هذا الإجراء. سيتم حذف النسخة نهائياً من السجل.",
"compareVersions": "مقارنة",
"currentVersion": "الحالي",
"diffSelectHint": "انقر على نسختين في القائمة للمقارنة بينهما",
"diffTitle": "المقارنة"
},
"pagination": {
"previous": "←",
@@ -221,81 +226,81 @@
"next": "→"
},
"labels": {
"title": "Labels",
"filter": "Filter by Label",
"manage": "Manage Labels",
"manageTooltip": "Manage Labels",
"changeColor": "Change Color",
"changeColorTooltip": "Change color",
"delete": "Delete",
"deleteTooltip": "Delete label",
"confirmDelete": "Are you sure you want to delete this label?",
"newLabelPlaceholder": "Create new label",
"namePlaceholder": "Enter label name",
"addLabel": "Add label",
"createLabel": "Create label",
"labelName": "Label name",
"labelColor": "Label color",
"manageLabels": "Manage labels",
"manageLabelsDescription": "Add or remove labels for this note. Click on a label to change its color.",
"selectedLabels": "Selected Labels",
"allLabels": "All Labels",
"clearAll": "Clear all",
"filterByLabel": "Filter by label",
"tagAdded": "Tag \"{tag}\" added",
"showLess": "Show less",
"showMore": "Show more",
"editLabels": "Edit Labels",
"editLabelsDescription": "Create, edit colors, or delete labels.",
"noLabelsFound": "No labels found.",
"loading": "Loading...",
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.",
"count": "{count} labels",
"noLabels": "No labels",
"confirmDeleteShort": "Confirm?",
"labelRemoved": "Label \"{label}\" removed"
"title": "التسميات",
"filter": "تصفية حسب التسمية",
"manage": "إدارة التسميات",
"manageTooltip": "إدارة التسميات",
"changeColor": "تغيير اللون",
"changeColorTooltip": "تغيير اللون",
"delete": "حذف",
"deleteTooltip": "حذف التسمية",
"confirmDelete": "هل أنت متأكد أنك تريد حذف هذه التسمية؟",
"newLabelPlaceholder": "إنشاء تسمية جديدة",
"namePlaceholder": "أدخل اسم التسمية",
"addLabel": "إضافة تسمية",
"createLabel": "إنشاء تسمية",
"labelName": "اسم التسمية",
"labelColor": "لون التسمية",
"manageLabels": "إدارة التسميات",
"manageLabelsDescription": "إضافة أو إزالة تسميات لهذه الملاحظة. انقر على التسمية لتغيير لونها.",
"selectedLabels": "التسميات المحددة",
"allLabels": "جميع التسميات",
"clearAll": "مسح الكل",
"filterByLabel": "تصفية حسب التسمية",
"tagAdded": "تمت إضافة الوسم \"{tag}\"",
"showLess": "عرض أقل",
"showMore": "عرض المزيد",
"editLabels": "تعديل التسميات",
"editLabelsDescription": "إنشاء أو تعديل الألوان أو حذف التسميات.",
"noLabelsFound": "لم يتم العثور على تسميات.",
"loading": "جاري التحميل...",
"notebookRequired": "⚠️ التسميات متاحة فقط في الدفاتر. انقل هذه الملاحظة إلى دفتر أولاً.",
"count": "{count} تسميات",
"noLabels": "لا توجد تسميات",
"confirmDeleteShort": "تأكيد؟",
"labelRemoved": "تمت إزالة التسمية \"{label}\""
},
"search": {
"placeholder": "Search",
"searchPlaceholder": "Search your notes...",
"semanticInProgress": "AI search in progress...",
"semanticTooltip": "AI semantic search",
"searching": "Searching...",
"noResults": "No results found",
"resultsFound": "{count} notes found",
"exactMatch": "Exact match",
"related": "Related",
"placeholder": "بحث",
"searchPlaceholder": "ابحث في ملاحظاتك...",
"semanticInProgress": "بحث الذكاء الاصطناعي جارٍ...",
"semanticTooltip": "بحث دلالي بالذكاء الاصطناعي",
"searching": "جاري البحث...",
"noResults": "لم يتم العثور على نتائج",
"resultsFound": "تم العثور على {count} ملاحظات",
"exactMatch": "تطابق تام",
"related": "ذات صلة",
"disabledAdmin": "البحث معطل في وضع المسؤول"
},
"collaboration": {
"emailPlaceholder": "Enter email address",
"addCollaborator": "Add collaborator",
"removeCollaborator": "Remove collaborator",
"owner": "Owner",
"canEdit": "Can edit",
"canView": "Can view",
"shareNote": "Share note",
"shareWithCollaborators": "Share with collaborators",
"addCollaboratorDescription": "Add people to collaborate on this note by their email address.",
"viewerDescription": "You have access to this note. Only the owner can manage collaborators.",
"emailAddress": "Email address",
"enterEmailAddress": "Enter email address",
"invite": "Invite",
"peopleWithAccess": "People with access",
"noCollaborators": "No collaborators yet. Add someone above!",
"noCollaboratorsViewer": "No collaborators yet.",
"pendingInvite": "Pending Invite",
"pending": "Pending",
"remove": "Remove",
"unnamedUser": "Unnamed User",
"done": "Done",
"willBeAdded": "{email} will be added as collaborator when note is created",
"alreadyInList": "This email is already in the list",
"nowHasAccess": "{name} now has access to this note",
"accessRevoked": "Access has been revoked",
"errorLoading": "Error loading collaborators",
"failedToAdd": "Failed to add collaborator",
"failedToRemove": "Failed to remove collaborator"
"emailPlaceholder": "أدخل عنوان البريد الإلكتروني",
"addCollaborator": "إضافة متعاون",
"removeCollaborator": "إزالة متعاون",
"owner": "المالك",
"canEdit": "يمكنه التعديل",
"canView": "يمكنه العرض",
"shareNote": "مشاركة الملاحظة",
"shareWithCollaborators": "المشاركة مع المتعاونين",
"addCollaboratorDescription": "أضف أشخاصاً للتعاون في هذه الملاحظة عبر عنوان بريدهم الإلكتروني.",
"viewerDescription": "لديك صلاحية الوصول إلى هذه الملاحظة. فقط المالك يمكنه إدارة المتعاونين.",
"emailAddress": "عنوان البريد الإلكتروني",
"enterEmailAddress": "أدخل عنوان البريد الإلكتروني",
"invite": "دعوة",
"peopleWithAccess": "الأشخاص الذين لديهم صلاحية الوصول",
"noCollaborators": "لا يوجد متعاونون بعد. أضف شخصاً أعلاه!",
"noCollaboratorsViewer": "لا يوجد متعاونون بعد.",
"pendingInvite": "دعوة معلقة",
"pending": "معلق",
"remove": "إزالة",
"unnamedUser": "مستخدم بدون اسم",
"done": "تم",
"willBeAdded": "سيتم إضافة {email} كمتعاون عند إنشاء الملاحظة",
"alreadyInList": "هذا البريد الإلكتروني موجود بالفعل في القائمة",
"nowHasAccess": "{name} لديه الآن صلاحية الوصول إلى هذه الملاحظة",
"accessRevoked": "تم إلغاء صلاحية الوصول",
"errorLoading": "خطأ في تحميل المتعاونين",
"failedToAdd": "فشل في إضافة المتعاون",
"failedToRemove": "فشل في إزالة المتعاون"
},
"ai": {
"analyzing": "الذكاء الاصطناعي يحلل...",
@@ -417,14 +422,17 @@
"transformationsDesc": "التحويلات — مطبقة مباشرة في الملاحظة",
"writeMinWordsAction": "اكتب 5 كلمات على الأقل لتفعيل إجراءات الذكاء الاصطناعي.",
"processingAction": "جاري المعالجة...",
"noImagesError": "No images in this note",
"overview": "Overview",
"noImagesError": "لا توجد صور في هذه الملاحظة",
"overview": "نظرة عامة",
"action": {
"clarify": "توضيح",
"shorten": "تقصير",
"improve": "تحسين",
"toMarkdown": "إلى Markdown",
"describeImages": "Describe images"
"describeImages": "وصف الصور",
"fixGrammar": "تصحيح القواعد",
"translate": "ترجمة",
"explain": "شرح"
},
"openAssistant": "فتح مساعد الذكاء الاصطناعي",
"poweredByMomento": "مدعوم من Momento AI",
@@ -440,8 +448,50 @@
"insightsTab": "رؤى",
"aiCopilot": "مساعد ذكي",
"suggestTitle": "اقتراح عنوان بالذكاء الاصطناعي",
"generateTitleFromImage": "Generate title from image",
"titleGenerated": "Title generated from image"
"generateTitleFromImage": "إنشاء عنوان من الصورة",
"titleGenerated": "تم إنشاء العنوان من الصورة",
"wordCountMin": "الرجاء تحديد {min} كلمات على الأقل لإعادة الصياغة (حالياً {current} كلمة)",
"wordCountMax": "الرجاء تحديد {max} كلمة كحد أقصى لإعادة الصياغة (حالياً {current} كلمة)",
"resourceTab": "المصدر",
"aiNoteTitle": "ملاحظة الذكاء الاصطناعي",
"injectReplace": "استبدال",
"injectReplaceTitle": "استبدال محتوى الملاحظة بهذه الرسالة",
"injectComplete": "إكمال",
"injectCompleteTitle": "إكمال الملاحظة بهذه الرسالة (ذكاء اصطناعي)",
"injectMerge": "دمج",
"injectMergeTitle": "دمج مع الملاحظة (ذكاء اصطناعي)",
"imagesCount": "{count} صور",
"resource": {
"failedToLoadUrl": "فشل تحميل هذا الرابط",
"pageLoaded": "تم تحميل الصفحة: {title}",
"pageLoadError": "خطأ في تحميل الصفحة",
"pasteOrUrlFirst": "الصق نصاً أو حمّل رابطاً أولاً",
"enrichError": "خطأ في الإثراء",
"enrichErrorShort": "خطأ في الإثراء",
"contentApplied": "تم تطبيق المحتوى على الملاحظة ✓",
"fromChat": "💬 من الدردشة",
"replacement": "↓ استبدال",
"completedByAI": "✦ أكمله الذكاء الاصطناعي",
"mergedByAI": "⟳ دمجه الذكاء الاصطناعي",
"rendered": "تم العرض",
"cancel": "إلغاء",
"applyToNote": "تطبيق على الملاحظة",
"urlLabel": "رابط URL (اختياري)",
"resourceText": "نص المصدر",
"resourcePlaceholder": "الصق النص هنا (ماركداون، HTML، نص عادي…)",
"words": "كلمات",
"integrationMode": "وضع التكامل",
"modeReplace": "استبدال",
"modeReplaceDesc": "مباشر، بدون ذكاء اصطناعي",
"modeComplete": "إكمال",
"modeCompleteDesc": "يضيف بدون إعادة كتابة",
"modeMerge": "دمج",
"modeMergeDesc": "يعيد الكتابة ويكامل",
"aiProcessing": "جاري المعالجة بالذكاء الاصطناعي…",
"preview": "معاينة",
"generatePreview": "إنشاء معاينة",
"emptyNoteHint": "💡 الملاحظة فارغة — سيتم دمج محتوى المصدر مباشرة."
}
},
"titleSuggestions": {
"available": "اقتراحات العنوان",
@@ -533,16 +583,16 @@
"confirmFusion": "تأكيد الدمج",
"success": "تم دمج الملاحظات بنجاح!",
"error": "فشل في دمج الملاحظات",
"generateError": "Failed to generate fusion",
"noContentReturned": "No fusion content returned from API",
"unknownDate": "Unknown date"
"generateError": "فشل في إنشاء الدمج",
"noContentReturned": "لم يتم إرجاع محتوى دمج من API",
"unknownDate": "تاريخ غير معروف"
}
},
"notification": {
"accept": "Accept",
"accepted": "Share accepted",
"decline": "Decline",
"noNotifications": "No new notifications",
"accept": "قبول",
"accepted": "تم قبول المشاركة",
"decline": "رفض",
"noNotifications": "لا توجد إشعارات جديدة",
"shared": "شارك \"{title}\"",
"untitled": "بدون عنوان",
"notifications": "الإشعارات",
@@ -603,24 +653,24 @@
"about": "حول",
"version": "الإصدار",
"settingsSaved": "تم حفظ الإعدادات",
"cardSizeMode": "Note Size",
"cardSizeModeDescription": "Choose between variable sizes or uniform size",
"selectCardSizeMode": "Select display mode",
"cardSizeVariable": "Variable sizes (small/medium/large)",
"cardSizeUniform": "Uniform size",
"cardSizeMode": "حجم الملاحظة",
"cardSizeModeDescription": "اختر بين أحجام متغيرة أو حجم موحد",
"selectCardSizeMode": "اختر وضع العرض",
"cardSizeVariable": "أحجام متغيرة (صغير/متوسط/كبير)",
"cardSizeUniform": "حجم موحد",
"settingsError": "خطأ في حفظ الإعدادات",
"maintenance": "Maintenance",
"maintenanceDescription": "Tools to maintain your database health",
"cleanTags": "Clean Orphan Tags",
"cleanTagsDescription": "Remove tags that are no longer used by any notes",
"maintenance": "الصيانة",
"maintenanceDescription": "أدوات للحفاظ على صحة قاعدة البيانات",
"cleanTags": "تنظيف الوسوم اليتيمة",
"cleanTagsDescription": "إزالة الوسوم التي لم تعد مستخدمة في أي ملاحظة",
"cleanupDone": "تمت مزامنة {created} تسمية، حذف {deleted} يتيمة",
"cleanupNothing": "لا حاجة لأي إجراء — التسميات متزامنة بالفعل مع ملاحظاتك",
"cleanupWithErrors": "بعض العمليات فشلت",
"cleanupError": "تعذر تنظيف التسميات",
"indexingComplete": "اكتملت الفهرسة: تمت معالجة {count} ملاحظة",
"indexingError": "خطأ أثناء الفهرسة",
"semanticIndexing": "Semantic Indexing",
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
"semanticIndexing": "الفهرسة الدلالية",
"semanticIndexingDescription": "إنشاء متجهات لجميع الملاحظات لتفعيل البحث القائم على النية",
"profile": "الملف الشخصي",
"searchNoResults": "لم يتم العثور على إعدادات مطابقة",
"languageAuto": "الكشف التلقائي",
@@ -670,10 +720,10 @@
"fontSizeDescription": "قم بضبط حجم الخط لتحسين القراءة. ينطبق هذا على جميع النصوص في الواجهة.",
"fontSizeUpdateSuccess": "تم تحديث حجم الخط بنجاح",
"fontSizeUpdateFailed": "فشل في تحديث حجم الخط",
"showRecentNotes": "Show Recent Notes Section",
"showRecentNotesDescription": "Display recent notes (last 7 days) on the main page",
"recentNotesUpdateSuccess": "Recent notes setting updated successfully",
"recentNotesUpdateFailed": "Failed to update recent notes setting"
"showRecentNotes": "عرض قسم الملاحظات الحديثة",
"showRecentNotesDescription": "عرض الملاحظات الحديثة (آخر 7 أيام) في الصفحة الرئيسية",
"recentNotesUpdateSuccess": "تم تحديث إعدادات الملاحظات الحديثة بنجاح",
"recentNotesUpdateFailed": "فشل تحديث إعدادات الملاحظات الحديثة"
},
"aiSettings": {
"title": "إعدادات الذكاء الاصطناعي",
@@ -695,7 +745,15 @@
"providerDesc": "اختر مزود الذكاء الاصطناعي المفضل",
"providerAutoDesc": "Ollama عند توفره، OpenAI كبديل",
"providerOllamaDesc": "خصوصية 100%، يعمل محليًا",
"providerOpenAIDesc": "الأكثر دقة، يتطلب مفتاح API"
"providerOpenAIDesc": "الأكثر دقة، يتطلب مفتاح API",
"aiNote": "ملاحظة الذكاء الاصطناعي",
"aiNoteDesc": "تفعيل زر دردشة الذكاء الاصطناعي وأدوات تحسين النص",
"languageDetection": "اكتشاف اللغة",
"languageDetectionDesc": "يكتشف تلقائياً لغة ملاحظاتك",
"autoLabeling": "اقتراحات التسميات",
"autoLabelingDesc": "يقترح ويطبق التسميات تلقائياً على ملاحظاتك",
"noteHistory": "سجل الملاحظة",
"noteHistoryDesc": "تفعيل لقطات النسخ والاستعادة من السجل"
},
"general": {
"loading": "جاري التحميل...",
@@ -717,9 +775,9 @@
"error": "حدث خطأ",
"operationSuccess": "نجحت العملية",
"operationFailed": "فشلت العملية",
"testConnection": "Test Connection",
"clean": "Clean",
"indexAll": "Index All",
"testConnection": "اختبار الاتصال",
"clean": "تنظيف",
"indexAll": "فهرسة الكل",
"preview": "معاينة"
},
"colors": {
@@ -752,7 +810,9 @@
"markDone": "وضع علامة مكتمل",
"markUndone": "وضع علامة غير مكتمل",
"todayAt": "اليوم في {time}",
"tomorrowAt": "غداً في {time}"
"tomorrowAt": "غداً في {time}",
"clearCompleted": "مسح المكتملة",
"viewAll": "عرض جميع التذكيرات"
},
"notebook": {
"create": "إنشاء دفتر",
@@ -783,7 +843,7 @@
"confidence": "ثقة",
"savingReminder": "خطأ في حفظ التذكير",
"removingReminder": "خطأ في إزالة التذكير",
"generatingDescription": "Please wait..."
"generatingDescription": "يرجى الانتظار..."
},
"notebookSuggestion": {
"title": "النقل إلى {name}؟",
@@ -797,10 +857,10 @@
"admin": {
"title": "لوحة تحكم المشرف",
"userManagement": "إدارة المستخدمين",
"chat": "AI Chat",
"lab": "The Lab",
"agents": "Agents",
"workspace": "Workspace",
"chat": "دردشة الذكاء الاصطناعي",
"lab": "المختبر",
"agents": "الوكلاء",
"workspace": "مساحة العمل",
"settings": "إعدادات المشرف",
"security": {
"title": "إعدادات الأمان",
@@ -896,14 +956,14 @@
"description": "تكوين إرسال البريد الإلكتروني لإشعارات الوكلاء وإعادة تعيين كلمة المرور.",
"provider": "مزود البريد الإلكتروني",
"saveSettings": "حفظ إعدادات البريد الإلكتروني",
"status": "Service Status",
"keySet": "key configured",
"activeAuto": "Auto mode: Resend will be used first, SMTP as fallback.",
"activeSmtp": "Auto mode: SMTP will be used (Resend not configured).",
"noneConfigured": "No email service configured. Set up Resend or SMTP.",
"activeProvider": "Active provider",
"testOk": "test passed",
"testFail": "test failed"
"status": "حالة الخدمة",
"keySet": "تم تكوين المفتاح",
"activeAuto": "الوضع التلقائي: سيتم استخدام Resend أولاً، ثم SMTP كبديل.",
"activeSmtp": "الوضع التلقائي: سيتم استخدام SMTP (Resend غير مكوّن).",
"noneConfigured": "لم يتم تكوين خدمة بريد إلكتروني. قم بإعداد Resend أو SMTP.",
"activeProvider": "المزود النشط",
"testOk": "نجح الاختبار",
"testFail": "فشل الاختبار"
},
"smtp": {
"title": "تكوين SMTP",
@@ -937,9 +997,9 @@
"deleteFailed": "فشل الحذف",
"roleUpdateSuccess": "تم تحديث دور المستخدم إلى {role}",
"roleUpdateFailed": "فشل تحديث الدور",
"demote": "تخفيض",
"promote": "ترقية",
"confirmDelete": "Are you sure? This action cannot be undone.",
"demote": "تخفيض إلى مستخدم",
"promote": "ترقية إلى مشرف",
"confirmDelete": "هل أنت متأكد؟ لا يمكن التراجع عن هذا الإجراء.",
"table": {
"name": "الاسم",
"email": "البريد الإلكتروني",
@@ -1182,7 +1242,7 @@
"notesViewLabel": "عرض الملاحظات",
"notesViewTabs": "علامات تبويب (نمط OneNote)",
"notesViewMasonry": "بطاقات (شبكة)",
"selectTheme": "Select theme",
"selectTheme": "اختر المظهر",
"fontFamilyLabel": "عائلة الخطوط",
"fontFamilyDescription": "اختر الخط المستخدم في جميع أنحاء التطبيق",
"selectFontFamily": "Inter مُحسّن لسهولة القراءة، النظام يستخدم الخط الأصلي لنظام التشغيل",
@@ -1248,12 +1308,12 @@
},
"diagnostics": {
"title": "التشخيص",
"description": "Check your AI provider connection status",
"description": "تحقق من حالة اتصال مزود الذكاء الاصطناعي",
"configuredProvider": "المزود المكوّن",
"apiStatus": "حالة API",
"operational": "Operational",
"errorStatus": "Error",
"checking": "Checking...",
"operational": "يعمل",
"errorStatus": "خطأ",
"checking": "جاري التحقق...",
"testDetails": "تفاصيل الاختبار:",
"troubleshootingTitle": "نصائح استكشاف الأخطاء:",
"tip1": "تأكد من تشغيل Ollama (ollama serve)",
@@ -1376,10 +1436,10 @@
"subtitle": "أتمتة مهام المراقبة والبحث الخاصة بك",
"newAgent": "وكيل جديد",
"myAgents": "وكلائي",
"searchPlaceholder": "Search agents...",
"filterAll": "All",
"newBadge": "New",
"noResults": "No agents match your search.",
"searchPlaceholder": "البحث عن وكلاء...",
"filterAll": "الكل",
"newBadge": "جديد",
"noResults": "لا يوجد وكلاء يطابقون بحثك.",
"noAgents": "لا يوجد وكلاء",
"noAgentsDescription": "أنشئ أول وكيل لك أو ثبّت قالبًا أدناه لأتمتة مهام المراقبة.",
"types": {
@@ -1423,8 +1483,8 @@
"researchTopicPlaceholder": "مثال: أحدث التطورات في الذكاء الاصطناعي",
"notifyEmail": "إشعار بالبريد الإلكتروني",
"notifyEmailHint": "استلام بريد إلكتروني بنتائج الوكيل بعد كل تشغيل",
"includeImages": "Include images",
"includeImagesHint": "Extract images from scraped pages and attach them to the generated note"
"includeImages": "تضمين الصور",
"includeImagesHint": "استخراج الصور من الصفحات المجمعة وإرفاقها بالملاحظة المولدة"
},
"frequencies": {
"manual": "يدوي",
@@ -1434,19 +1494,19 @@
"monthly": "شهري"
},
"schedule": {
"nextRun": "Next run",
"pending": "Pending trigger",
"time": "Time",
"dayOfWeek": "Day of week",
"dayOfMonth": "Day of month",
"nextRun": "التنفيذ التالي",
"pending": "في انتظار التشغيل",
"time": "الوقت",
"dayOfWeek": "يوم الأسبوع",
"dayOfMonth": "يوم الشهر",
"days": {
"mon": "Monday",
"tue": "Tuesday",
"wed": "Wednesday",
"thu": "Thursday",
"fri": "Friday",
"sat": "Saturday",
"sun": "Sunday"
"mon": "الاثنين",
"tue": "الثلاثاء",
"wed": "الأربعاء",
"thu": "الخميس",
"fri": "الجمعة",
"sat": "السبت",
"sun": "الأحد"
}
},
"status": {
@@ -1478,8 +1538,8 @@
"installSuccess": "تم تثبيت \"{name}\"",
"installError": "خطأ أثناء التثبيت",
"saveError": "خطأ في الحفظ",
"autoRunSuccess": "Agent \"{name}\" executed automatically with success",
"autoRunError": "Agent \"{name}\" failed during automatic execution"
"autoRunSuccess": "تم تنفيذ الوكيل \"{name}\" تلقائياً بنجاح",
"autoRunError": "فشل الوكيل \"{name}\" أثناء التنفيذ التلقائي"
},
"templates": {
"title": "القوالب",
@@ -1593,7 +1653,7 @@
"searching": "جاري البحث...",
"noNotesFoundForContext": "لم يتم العثور على ملاحظات ذات صلة لهذا السؤال. أجب باستخدام معرفتك العامة.",
"webSearch": "بحث الويب",
"timeoutWarning": "Response is taking longer than expected..."
"timeoutWarning": "الاستجابة تستغرق وقتاً أطول من المتوقع..."
},
"labHeader": {
"title": "المختبر",
@@ -1611,10 +1671,81 @@
"deleteSpace": "حذف المساحة",
"deleted": "تم حذف المساحة",
"deleteError": "خطأ في الحذف",
"rename": "Rename"
"rename": "إعادة تسمية"
},
"lab": {
"initializing": "تهيئة المساحة",
"loadingIdeas": "جاري تحميل أفكارك..."
},
"richTextEditor": {
"slashHint": "↑↓ تنقل · Enter إدراج · Tab تبديل القسم",
"slashLoading": "الذكاء الاصطناعي يفكر...",
"slashTabAll": "الكل",
"slashCatBasic": "كتل أساسية",
"slashCatMedia": "وسائط",
"slashCatFormatting": "تنسيق",
"slashCatAi": "ملاحظة ذكاء اصطناعي",
"insertImage": "إدراج صورة",
"imageUrlPlaceholder": "https://example.com/image.png",
"preview": "معاينة",
"cancel": "إلغاء",
"insert": "إدراج",
"slashText": "نص",
"slashTextDesc": "فقرة بسيطة",
"slashH1": "عنوان 1",
"slashH1Desc": "عنوان قسم كبير",
"slashH2": "عنوان 2",
"slashH2Desc": "عنوان قسم متوسط",
"slashH3": "عنوان 3",
"slashH3Desc": "عنوان قسم صغير",
"slashBullet": "قائمة نقطية",
"slashBulletDesc": "قائمة غير مرتبة",
"slashNumbered": "قائمة مرقمة",
"slashNumberedDesc": "قائمة مرقمة مرتبة",
"slashTodo": "قائمة مهام",
"slashTodoDesc": "مهام ب مربع اختيار",
"slashQuote": "اقتباس",
"slashQuoteDesc": "التقاط اقتباس",
"slashCode": "كتلة كود",
"slashCodeDesc": "مقتطف كود",
"slashDivider": "فاصل",
"slashDividerDesc": "فاصل أفقي",
"slashImage": "صورة",
"slashImageDesc": "تضمين صورة من رابط",
"slashAlignLeft": "محاذاة لليسار",
"slashAlignLeftDesc": "محاذاة النص لليسار",
"slashAlignCenter": "توسيط",
"slashAlignCenterDesc": "توسيط النص",
"slashAlignRight": "محاذاة لليمين",
"slashAlignRightDesc": "محاذاة النص لليمين",
"slashSuperscript": "نص مرتفع",
"slashSuperscriptDesc": "نص فوق خط الأساس",
"slashSubscript": "نص منخفض",
"slashSubscriptDesc": "نص تحت خط الأساس",
"slashClarify": "توضيح",
"slashClarifyDesc": "جعل النص أوضح",
"slashShorten": "اختصار",
"slashShortenDesc": "تكثيف النص",
"slashImprove": "تحسين",
"slashImproveDesc": "تحسين الأسلوب",
"slashExpand": "توسيع",
"slashExpandDesc": "توسيع وإثراء النص",
"imageModalTitle": "إدراج صورة",
"imageModalPreview": "معاينة",
"imageModalCancel": "إلغاء",
"imageModalInsert": "إدراج",
"imageModalInvalidUrl": "الرجاء إدخال رابط صالح",
"imageModalLoadFailed": "فشل تحميل الصورة",
"linkPlaceholder": "الصق أو اكتب رابطاً...",
"bold": "عريض",
"italic": "مائل",
"underline": "تسطير",
"strike": "يتوسطه خط",
"code": "كود",
"highlight": "تمييز",
"superscript": "نص مرتفع",
"subscript": "نص منخفض",
"addBlock": "إضافة كتلة",
"placeholder": "اكتب '/' للأوامر..."
}
}
}

View File

@@ -429,7 +429,10 @@
"shorten": "خلاصه کردن",
"improve": "بهبود",
"toMarkdown": "به مارک‌داون",
"describeImages": "توصیف تصاویر"
"describeImages": "توصیف تصاویر",
"fixGrammar": "اصلاح گرامر",
"translate": "ترجمه",
"explain": "توضیح"
},
"openAssistant": "باز کردن دستیار هوش مصنوعی",
"poweredByMomento": "پشتیبانی شده توسط Momento AI",

View File

@@ -55,6 +55,7 @@
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"isomorphic-dompurify": "^3.12.0",
"jsdom": "^29.0.2",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",
@@ -8769,9 +8770,9 @@
}
},
"node_modules/dompurify": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz",
"integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
"integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -9544,6 +9545,19 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/isomorphic-dompurify": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.12.0.tgz",
"integrity": "sha512-8n+j+6ypTHvriJwFOQ2qusQ6bzGjZVcR3jbe1pBpLcGI1dn4WIl0ctLBngqE5QttquQBAlKXwJeTMw+X7x7qKw==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.4.2",
"jsdom": "^29.1.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
}
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -9641,27 +9655,27 @@
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.0.2",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.5",
"@asamuzakjp/dom-selector": "^7.0.6",
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7",
"parse5": "^8.0.0",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.24.5",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",

View File

@@ -72,6 +72,7 @@
"date-fns": "^4.1.0",
"diff": "^9.0.0",
"dotenv": "^17.2.3",
"isomorphic-dompurify": "^3.12.0",
"jsdom": "^29.0.2",
"katex": "^0.16.27",
"lucide-react": "^0.562.0",

View File

@@ -1,54 +0,0 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '..', 'components', 'contextual-ai-chat.tsx');
let src = fs.readFileSync(filePath, 'utf8');
// ─── FIX 1: make inject buttons always visible on last assistant msg ──────────
// Replace the hover-gated condition with always-visible for last msg
const OLD_BUTTONS_CONDITION = ` {/* Hover-actions \u2014 visible only on assistant messages */}\r\n {isAssistant && onApplyToNote && (\r\n <div className={cn(\r\n 'flex gap-1 transition-all duration-150',\r\n isHovered ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1 pointer-events-none',\r\n )}>`;
const NEW_BUTTONS_CONDITION = ` {/* Inject buttons \u2014 always visible on last assistant msg */}\r\n {isAssistant && onApplyToNote && (() => {\r\n const lastAssistantId = messages.filter(m => m.role === 'assistant').at(-1)?.id\r\n const alwaysShow = msg.id === lastAssistantId\r\n return (\r\n <div className={cn(\r\n 'flex gap-1 transition-all duration-150',\r\n (alwaysShow || isHovered) ? 'opacity-100' : 'opacity-0 pointer-events-none',\r\n )}>`;
// Also need to close the IIFE
const OLD_BUTTONS_CLOSE = ` </div>\r\n )}\r\n </div>`;
const NEW_BUTTONS_CLOSE = ` </div>\r\n )\r\n })()}\r\n </div>`;
if (src.includes(OLD_BUTTONS_CONDITION)) {
src = src.replace(OLD_BUTTONS_CONDITION, NEW_BUTTONS_CONDITION);
console.log('Fix 1a: buttons condition updated');
} else {
console.error('Fix 1a: OLD_BUTTONS_CONDITION not found!');
process.exit(1);
}
if (src.includes(OLD_BUTTONS_CLOSE)) {
src = src.replace(OLD_BUTTONS_CLOSE, NEW_BUTTONS_CLOSE);
console.log('Fix 1b: buttons IIFE close updated');
} else {
console.error('Fix 1b: OLD_BUTTONS_CLOSE not found!');
process.exit(1);
}
// ─── FIX 2: Move preview panel OUTSIDE the scrollable div ────────────────────
// The scrollable div ends with: messagesEndRef then </div>
// We need to:
// A) Remove the preview block from inside the scrollable area (already was removed in previous sessions since it was added after messagesEndRef)
// B) Add preview as shrink-0 panel between the scroll div and controls
// Find the closing of the scrollable area + start of controls
const OLD_SCROLL_END = ` <div ref={messagesEndRef} />\r\n </div>\r\n\r\n {/* Scope & Tone Control Area */}`;
const NEW_SCROLL_END = ` <div ref={messagesEndRef} />\r\n </div>\r\n\r\n {/* \u2550\u2550 Inject Preview Panel \u2014 fixed, always visible, NO scroll needed \u2550\u2550 */}\r\n {resourceEnriching && (\r\n <div className="shrink-0 mx-3 mb-2 flex items-center gap-2 rounded-xl border border-emerald-500/30 bg-emerald-50/50 dark:bg-emerald-950/20 p-3">\r\n <Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-600 shrink-0" />\r\n <span className="text-xs text-emerald-700 dark:text-emerald-400">Traitement IA en cours...</span>\r\n </div>\r\n )}\r\n {resourcePreview && !resourceEnriching && (\r\n <div className="shrink-0 mx-3 mb-2 rounded-xl border border-primary/30 bg-primary/5 overflow-hidden">\r\n <div className="flex items-center justify-between px-3 py-2 border-b border-primary/20">\r\n <span className="text-[10px] font-semibold uppercase tracking-wider text-primary">\r\n {resourcePreview.source === 'complete' ? 'Compl\u00e9t\u00e9 par IA'\r\n : resourcePreview.source === 'merge' ? 'Fusionn\u00e9 par IA'\r\n : 'Aper\u00e7u'}\r\n </span>\r\n <button onClick={() => setResourcePreview(null)} className="text-muted-foreground hover:text-foreground">\r\n <X className="h-3 w-3" />\r\n </button>\r\n </div>\r\n <div className="px-3 py-2 max-h-36 overflow-y-auto text-sm">\r\n <MarkdownContent content={resourcePreview.text} />\r\n </div>\r\n <div className="flex gap-2 px-3 py-2 border-t border-primary/20">\r\n <button\r\n onClick={() => setResourcePreview(null)}\r\n className="flex-1 text-[11px] py-1.5 rounded-lg border border-border/60 text-muted-foreground hover:bg-muted transition-colors"\r\n >\r\n Annuler\r\n </button>\r\n <button\r\n onClick={() => {\r\n if (onApplyToNote && resourcePreview) {\r\n onApplyToNote(resourcePreview.text)\r\n setResourcePreview(null)\r\n setResourceText('')\r\n toast.success('Appliqu\u00e9 \u00e0 la note')\r\n }\r\n }}\r\n disabled={!onApplyToNote}\r\n className="flex-1 text-[11px] py-1.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 flex items-center justify-center gap-1"\r\n >\r\n <Check className="h-3 w-3" />\r\n Appliquer \u00e0 la note\r\n </button>\r\n </div>\r\n </div>\r\n )}\r\n\r\n {/* Scope & Tone Control Area */}`;
if (src.includes(OLD_SCROLL_END)) {
src = src.replace(OLD_SCROLL_END, NEW_SCROLL_END);
console.log('Fix 2: preview panel moved outside scroll');
} else {
console.error('Fix 2: OLD_SCROLL_END not found!');
// Show what we're looking for context
const idx = src.indexOf('messagesEndRef');
console.error('Context around messagesEndRef:', JSON.stringify(src.slice(idx-20, idx+200)));
process.exit(1);
}
fs.writeFileSync(filePath, src);
console.log('Done!');

View File

@@ -1,51 +0,0 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '..', 'components', 'contextual-ai-chat.tsx');
let src = fs.readFileSync(filePath, 'utf8');
// Sync presets buttons
const OLD_PRESET_CLICK = `onClick={() => setTranslateTarget(l)}`;
const NEW_PRESET_CLICK = `onClick={() => { setTranslateTarget(l); setCustomLangInput(l); }}`;
if (src.includes(OLD_PRESET_CLICK)) {
src = src.replace(OLD_PRESET_CLICK, NEW_PRESET_CLICK);
console.log('Fix 1: Preset buttons sync updated');
} else {
console.error('Fix 1: OLD_PRESET_CLICK not found');
process.exit(1);
}
// Sync input and enable live update
const OLD_INPUT_LOGIC = `onChange={e => setCustomLangInput(e.target.value)}\r\n onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}`;
// Fallback for different line endings if needed, but let's try matching a smaller part first
const OLD_ONCHANGE = `onChange={e => setCustomLangInput(e.target.value)}`;
const NEW_ONCHANGE = `onChange={e => { const val = e.target.value; setCustomLangInput(val); setTranslateTarget(val); }}`;
const OLD_ONKEYDOWN = `onKeyDown={e => { if (e.key === 'Enter' && customLangInput.trim()) { setTranslateTarget(customLangInput.trim()); setCustomLangInput('') } }}`;
const NEW_ONKEYDOWN = `onKeyDown={e => { if (e.key === 'Enter' && translateTarget.trim()) { handleAction(action, translateTarget.trim()); } }}`;
if (src.includes(OLD_ONCHANGE)) {
src = src.replace(OLD_ONCHANGE, NEW_ONCHANGE);
console.log('Fix 2: Input onChange updated');
} else {
console.error('Fix 2: OLD_ONCHANGE not found');
process.exit(1);
}
if (src.includes(OLD_ONKEYDOWN)) {
src = src.replace(OLD_ONKEYDOWN, NEW_ONKEYDOWN);
console.log('Fix 3: Input onKeyDown updated');
} else {
console.error('Fix 3: OLD_ONKEYDOWN not found');
// Try without \r if it failed
const altOnKeyDown = OLD_ONKEYDOWN.replace('\r\n', '\n');
if (src.includes(altOnKeyDown)) {
src = src.replace(altOnKeyDown, NEW_ONKEYDOWN);
console.log('Fix 3: Input onKeyDown updated (alt line ending)');
} else {
process.exit(1);
}
}
fs.writeFileSync(filePath, src);
console.log('Done!');