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
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user