feat: RTL/i18n, AI translate+undo, no-refresh saves, settings perf

- RTL: force dir=rtl on LabelFilter, NotesViewToggle, LabelManagementDialog
- i18n: add missing keys (notifications, privacy, edit/preview, AI translate/undo)
- Settings pages: convert to Server Components (general, appearance) + loading skeleton
- AI menu: add Translate option (10 languages) + Undo AI button in toolbar
- Fix: saveInline uses REST API instead of Server Action → eliminates all implicit refreshes in list mode
- Fix: NotesTabsView notes sync effect preserves selected note on content changes
- Fix: auto-tag suggestions now filter already-assigned labels
- Fix: color change in card view uses local state (no refresh)
- Fix: nav links use <Link> for prefetching (Settings, Admin)
- Fix: suppress duplicate label suggestions already on note
- Route: add /api/ai/translate endpoint
This commit is contained in:
Sepehr Ramezani
2026-04-15 23:48:28 +02:00
parent 39671c6472
commit b6a548acd8
68 changed files with 5014 additions and 485 deletions

View File

@@ -24,7 +24,7 @@ export default async function MainLayout({
<Sidebar className="w-64 flex-none flex-col bg-white dark:bg-[#1e2128] border-r border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
{children}
</main>
</div>

View File

@@ -6,7 +6,8 @@ import { Note } from '@/lib/types'
import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { NoteInput } from '@/components/note-input'
import { MasonryGrid } from '@/components/masonry-grid'
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 { NoteEditor } from '@/components/note-editor'
@@ -25,6 +26,7 @@ import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Hea
import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
export default function HomePage() {
@@ -36,12 +38,14 @@ export default function HomePage() {
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
const [recentNotes, setRecentNotes] = useState<Note[]>([])
const [showRecentNotes, setShowRecentNotes] = useState(true)
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>('masonry')
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const { refreshKey } = useNoteRefresh()
const { labels } = useLabels()
const { setControls } = useHomeView()
// Auto label suggestion (IA4)
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
@@ -159,15 +163,23 @@ export default function HomePage() {
const load = async () => {
// Load settings first
let showRecent = true
let viewMode: NotesViewMode = 'masonry'
try {
const settings = await getAISettings()
if (cancelled) return
showRecent = settings?.showRecentNotes !== false
viewMode =
settings?.notesViewMode === 'masonry'
? 'masonry'
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
? 'tabs'
: 'masonry'
} catch {
// Default to true on error
}
if (cancelled) return
setShowRecentNotes(showRecent)
setNotesViewMode(viewMode)
// Then load notes
setIsLoading(true)
@@ -247,6 +259,14 @@ export default function HomePage() {
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])
// Get icon component for header
const getNotebookIcon = (iconName: string) => {
const ICON_MAP: Record<string, any> = {
@@ -282,11 +302,23 @@ export default function HomePage() {
</div>
)
const isTabs = notesViewMode === 'tabs'
return (
<main className="w-full px-8 py-6 flex flex-col h-full">
<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="flex flex-col gap-6 mb-8 animate-in fade-in slide-in-from-top-2 duration-300">
<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 */}
<Breadcrumbs notebookName={currentNotebook.name} />
@@ -308,7 +340,8 @@ export default function HomePage() {
</div>
{/* Actions Section */}
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
@@ -319,21 +352,28 @@ export default function HomePage() {
}}
className="border-gray-200"
/>
<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" />
Add Note
</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.addNote') || 'Add Note'}
</Button>
)}
</div>
</div>
</div>
) : (
/* Default Header for Home/Inbox */
<div className="flex flex-col gap-6 mb-8 animate-in fade-in slide-in-from-top-2 duration-300">
<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 Placeholder or just spacing */}
<div className="h-5 mb-1"></div>
{!isTabs && <div className="mb-1 h-5" />}
<div className="flex items-start justify-between">
{/* Title Section */}
@@ -345,7 +385,8 @@ export default function HomePage() {
</div>
{/* Actions Section */}
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
@@ -370,25 +411,31 @@ export default function HomePage() {
</Button>
)}
<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>
{!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>
)}
{/* Note Input - Conditionally Visible or Always Visible on Home */}
{/* Note Input - Conditionally Rendered */}
{showNoteInput && (
<div className="mb-8 animate-in fade-in slide-in-from-top-4 duration-300">
<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>
)}
@@ -397,26 +444,25 @@ export default function HomePage() {
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
{/* Favorites Section - Pinned Notes */}
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
{/* Recent Notes Section - Only shown if enabled in settings */}
{showRecentNotes && (
{!isTabs && showRecentNotes && (
<RecentNotesSection
recentNotes={recentNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
)}
{/* Main Notes Grid - Unpinned Notes Only */}
{notes.filter(note => !note.isPinned).length > 0 && (
<div data-testid="notes-grid">
<MasonryGrid
notes={notes.filter(note => !note.isPinned)}
{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 })}
currentNotebookId={searchParams.get('notebook')}
/>
</div>
)}
@@ -473,6 +519,6 @@ export default function HomePage() {
onClose={() => setEditingNote(null)}
/>
)}
</main>
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { updateUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
interface AppearanceSettingsClientProps {
initialFontSize: string
initialTheme: string
initialNotesViewMode: 'masonry' | 'tabs'
}
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode }: AppearanceSettingsClientProps) {
const { t } = useLanguage()
const [theme, setTheme] = useState(initialTheme || 'light')
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else {
root.setAttribute('data-theme', value)
if (['midnight'].includes(value)) root.classList.add('dark')
}
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
const fontSizeMap: Record<string, string> = {
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
}
document.documentElement.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
await updateAISettings({ fontSize: value as any })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleNotesViewChange = async (value: string) => {
const mode = value === 'tabs' ? 'tabs' : 'masonry'
setNotesViewMode(mode)
await updateAISettings({ notesViewMode: mode })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('appearance.description')}
</p>
</div>
<SettingsSection
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label={t('settings.theme')}
description={t('appearance.selectTheme')}
value={theme}
options={[
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
<SettingsSection
title={t('appearance.notesViewLabel')}
icon={<span className="text-2xl">📋</span>}
description={t('appearance.notesViewDescription')}
>
<SettingSelect
label={t('appearance.notesViewLabel')}
description={t('appearance.notesViewDescription')}
value={notesViewMode}
options={[
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
{ value: 'tabs', label: t('appearance.notesViewTabs') },
]}
onChange={handleNotesViewChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -1,113 +1,25 @@
'use client'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { getAISettings } from '@/app/actions/ai-settings'
import { getUserSettings } from '@/app/actions/user-settings'
import { AppearanceSettingsClient } from './appearance-settings-client'
import { useState, useEffect } from 'react'
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
export default function AppearanceSettingsPage() {
const { t } = useLanguage()
const [theme, setTheme] = useState('auto')
const [fontSize, setFontSize] = useState('medium')
// Load settings on mount
useEffect(() => {
async function loadSettings() {
try {
const [aiSettings, userSettings] = await Promise.all([
getAISettings(),
getUserSettings()
])
if (aiSettings.fontSize) setFontSize(aiSettings.fontSize)
if (userSettings.theme) setTheme(userSettings.theme)
} catch (error) {
console.error('Error loading settings:', error)
}
}
loadSettings()
}, [])
const handleThemeChange = async (value: string) => {
setTheme(value)
localStorage.setItem('theme-preference', value)
// Instant visual update
const root = document.documentElement
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (value === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
} else if (value === 'dark') {
root.classList.add('dark')
} else {
root.setAttribute('data-theme', value)
if (['midnight'].includes(value)) root.classList.add('dark')
}
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
export default async function AppearanceSettingsPage() {
const session = await auth()
if (!session?.user) {
redirect('/api/auth/signin')
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
// Instant visual update
const fontSizeMap: Record<string, string> = {
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
}
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
await updateAISettings({ fontSize: value as any })
}
const [aiSettings, userSettings] = await Promise.all([
getAISettings(),
getUserSettings()
])
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('appearance.description')}
</p>
</div>
<SettingsSection
title={t('settings.theme')}
icon={<span className="text-2xl">🎨</span>}
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
>
<SettingSelect
label={t('settings.theme')}
description={t('settings.selectLanguage')}
value={theme}
options={[
{ value: 'light', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'auto', label: t('settings.themeSystem') },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title={t('profile.fontSize')}
icon={<span className="text-2xl">📝</span>}
description={t('profile.fontSizeDescription')}
>
<SettingSelect
label={t('profile.fontSize')}
description={t('profile.selectFontSize')}
value={fontSize}
options={[
{ value: 'small', label: t('profile.fontSizeSmall') },
{ value: 'medium', label: t('profile.fontSizeMedium') },
{ value: 'large', label: t('profile.fontSizeLarge') },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
</div>
<AppearanceSettingsClient
initialFontSize={aiSettings.fontSize}
initialTheme={userSettings.theme}
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
/>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
interface GeneralSettingsClientProps {
initialSettings: {
preferredLanguage: string
emailNotifications: boolean
desktopNotifications: boolean
anonymousAnalytics: boolean
}
}
export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) {
const { t, setLanguage: setContextLanguage } = useLanguage()
const router = useRouter()
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
const [anonymousAnalytics, setAnonymousAnalytics] = useState(initialSettings.anonymousAnalytics ?? false)
const handleLanguageChange = async (value: string) => {
setLanguage(value)
await updateAISettings({ preferredLanguage: value as any })
if (value === 'auto') {
localStorage.removeItem('user-language')
toast.success(t('settings.languageAuto') || 'Language set to Auto')
} else {
localStorage.setItem('user-language', value)
setContextLanguage(value as any)
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
}
setTimeout(() => router.refresh(), 300)
}
const handleEmailNotificationsChange = async (enabled: boolean) => {
setEmailNotifications(enabled)
await updateAISettings({ emailNotifications: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleDesktopNotificationsChange = async (enabled: boolean) => {
setDesktopNotifications(enabled)
await updateAISettings({ desktopNotifications: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
setAnonymousAnalytics(enabled)
await updateAISettings({ anonymousAnalytics: enabled })
toast.success(t('settings.settingsSaved') || 'Saved')
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('generalSettings.description')}
</p>
</div>
<SettingsSection
title={t('settings.language')}
icon={<span className="text-2xl">🌍</span>}
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label={t('settings.language')}
description={t('settings.selectLanguage')}
value={language}
options={[
{ value: 'auto', label: t('profile.autoDetect') },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fa', label: 'فارسی' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
]}
onChange={handleLanguageChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.notifications')}
icon={<span className="text-2xl">🔔</span>}
description={t('settings.notificationsDesc')}
>
<SettingToggle
label={t('settings.emailNotifications')}
description={t('settings.emailNotificationsDesc')}
checked={emailNotifications}
onChange={handleEmailNotificationsChange}
/>
<SettingToggle
label={t('settings.desktopNotifications')}
description={t('settings.desktopNotificationsDesc')}
checked={desktopNotifications}
onChange={handleDesktopNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.privacy')}
icon={<span className="text-2xl">🔒</span>}
description={t('settings.privacyDesc')}
>
<SettingToggle
label={t('settings.anonymousAnalytics')}
description={t('settings.anonymousAnalyticsDesc')}
checked={anonymousAnalytics}
onChange={handleAnonymousAnalyticsChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -1,142 +1,15 @@
'use client'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { getAISettings } from '@/app/actions/ai-settings'
import { GeneralSettingsClient } from './general-settings-client'
import { useState, useEffect } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
export default function GeneralSettingsPage() {
const { t, setLanguage: setContextLanguage } = useLanguage()
const router = useRouter()
const [language, setLanguage] = useState('auto')
const [emailNotifications, setEmailNotifications] = useState(false)
const [desktopNotifications, setDesktopNotifications] = useState(false)
const [anonymousAnalytics, setAnonymousAnalytics] = useState(false)
// Load settings on mount
useEffect(() => {
async function loadSettings() {
try {
const settings = await getAISettings()
if (settings.preferredLanguage) setLanguage(settings.preferredLanguage)
if (settings.emailNotifications !== undefined) setEmailNotifications(settings.emailNotifications)
if (settings.desktopNotifications !== undefined) setDesktopNotifications(settings.desktopNotifications)
if (settings.anonymousAnalytics !== undefined) setAnonymousAnalytics(settings.anonymousAnalytics)
} catch (error) {
console.error('Error loading settings:', error)
}
}
loadSettings()
}, [])
const handleLanguageChange = async (value: string) => {
setLanguage(value)
// 1. Update database settings
await updateAISettings({ preferredLanguage: value as any })
// 2. Update local storage and application state
if (value === 'auto') {
localStorage.removeItem('user-language')
toast.success("Language set to Auto")
} else {
localStorage.setItem('user-language', value)
setContextLanguage(value as any)
toast.success(t('profile.languageUpdateSuccess') || "Language updated")
}
// 3. Refresh server components to ensure all components update (metadata, etc.)
setTimeout(() => router.refresh(), 500)
export default async function GeneralSettingsPage() {
const session = await auth()
if (!session?.user) {
redirect('/api/auth/signin')
}
const handleEmailNotificationsChange = async (enabled: boolean) => {
setEmailNotifications(enabled)
await updateAISettings({ emailNotifications: enabled })
}
const settings = await getAISettings()
const handleDesktopNotificationsChange = async (enabled: boolean) => {
setDesktopNotifications(enabled)
await updateAISettings({ desktopNotifications: enabled })
}
const handleAnonymousAnalyticsChange = async (enabled: boolean) => {
setAnonymousAnalytics(enabled)
await updateAISettings({ anonymousAnalytics: enabled })
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('generalSettings.description')}
</p>
</div>
<SettingsSection
title={t('settings.language')}
icon={<span className="text-2xl">🌍</span>}
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label={t('settings.language')}
description={t('settings.selectLanguage')}
value={language}
options={[
{ value: 'auto', label: t('profile.autoDetect') },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fa', label: 'فارسی' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
]}
onChange={handleLanguageChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.notifications')}
icon={<span className="text-2xl">🔔</span>}
description={t('settings.notifications')}
>
<SettingToggle
label={t('settings.notifications')}
description={t('settings.notifications')}
checked={emailNotifications}
onChange={handleEmailNotificationsChange}
/>
<SettingToggle
label={t('settings.notifications')}
description={t('settings.notifications')}
checked={desktopNotifications}
onChange={handleDesktopNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title={t('settings.privacy')}
icon={<span className="text-2xl">🔒</span>}
description={t('settings.privacy')}
>
<SettingToggle
label={t('settings.privacy')}
description={t('settings.privacy')}
checked={anonymousAnalytics}
onChange={handleAnonymousAnalyticsChange}
/>
</SettingsSection>
</div>
)
return <GeneralSettingsClient initialSettings={settings} />
}

View File

@@ -0,0 +1,26 @@
export default function SettingsLoading() {
return (
<div className="space-y-6 animate-pulse">
<div>
<div className="h-9 w-64 bg-muted rounded-md mb-2" />
<div className="h-4 w-96 bg-muted rounded-md" />
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border border-border p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 bg-muted rounded-full" />
<div className="h-5 w-40 bg-muted rounded-md" />
</div>
<div className="h-px bg-border" />
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30">
<div className="space-y-2">
<div className="h-4 w-32 bg-muted rounded" />
<div className="h-3 w-56 bg-muted rounded" />
</div>
<div className="h-6 w-11 bg-muted rounded-full" />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
export default async function McpSettingsPage() {
const session = await auth()
if (!session?.user) {
redirect('/api/auth/signin')
}
const [keys, serverStatus] = await Promise.all([
listMcpKeys(),
getMcpServerStatus(),
])
return (
<div className="space-y-6">
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
</div>
)
}

View File

@@ -14,13 +14,54 @@ export type UserAISettingsData = {
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: 'masonry' | 'tabs' | 'list'
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
theme?: 'light' | 'dark' | 'auto'
fontSize?: 'small' | 'medium' | 'large'
}
/** Only fields that exist on `UserAISettings` in Prisma (excludes e.g. `theme`, which lives on `User`). */
const USER_AI_SETTINGS_PRISMA_KEYS = [
'titleSuggestions',
'semanticSearch',
'paragraphRefactor',
'memoryEcho',
'memoryEchoFrequency',
'aiProvider',
'preferredLanguage',
'fontSize',
'demoMode',
'showRecentNotes',
'notesViewMode',
'emailNotifications',
'desktopNotifications',
'anonymousAnalytics',
] as const
type UserAISettingsPrismaKey = (typeof USER_AI_SETTINGS_PRISMA_KEYS)[number]
function pickUserAISettingsForDb(input: UserAISettingsData): Partial<Record<UserAISettingsPrismaKey, unknown>> {
const out: Partial<Record<UserAISettingsPrismaKey, unknown>> = {}
for (const key of USER_AI_SETTINGS_PRISMA_KEYS) {
const v = input[key]
if (v !== undefined) {
out[key] = v
}
}
if (out.notesViewMode === 'list') {
out.notesViewMode = 'tabs'
}
if (
out.notesViewMode != null &&
out.notesViewMode !== 'masonry' &&
out.notesViewMode !== 'tabs'
) {
delete out.notesViewMode
}
return out
}
/**
* Update AI settings for the current user
*/
@@ -35,24 +76,41 @@ export async function updateAISettings(settings: UserAISettingsData) {
}
try {
const data = pickUserAISettingsForDb(settings)
if (Object.keys(data).length === 0) {
return { success: true }
}
// Valeurs scalaires uniquement (pickUserAISettingsForDb) — cast pour éviter UpdateOperations vs create.
const payload = data as Record<string, string | boolean | undefined>
// Upsert settings (create if not exists, update if exists)
const result = await prisma.userAISettings.upsert({
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
...settings
...payload,
},
update: settings
update: payload,
})
revalidatePath('/settings/ai', 'page')
revalidatePath('/settings/appearance', 'page')
revalidatePath('/', 'layout')
updateTag('ai-settings')
return { success: true }
} catch (error) {
console.error('Error updating AI settings:', error)
const raw = error instanceof Error ? error.message : String(error)
const isSchema =
/no such column|notesViewMode|Unknown column|does not exist/i.test(raw) ||
(typeof raw === 'string' && raw.includes('UserAISettings') && raw.includes('column'))
if (isSchema) {
throw new Error(
'Schéma base de données obsolète : colonne notesViewMode manquante. Dans le dossier keep-notes, exécutez : npx prisma db push (ou appliquez les migrations Prisma).'
)
}
throw new Error('Failed to update AI settings')
}
}
@@ -81,6 +139,7 @@ const getCachedAISettings = unstable_cache(
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
@@ -89,6 +148,14 @@ const getCachedAISettings = unstable_cache(
}
}
const raw = settings.notesViewMode
const viewMode =
raw === 'masonry'
? ('masonry' as const)
: raw === 'list' || raw === 'tabs'
? ('tabs' as const)
: ('masonry' as const)
return {
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
@@ -99,6 +166,7 @@ const getCachedAISettings = unstable_cache(
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode,
showRecentNotes: settings.showRecentNotes,
notesViewMode: viewMode,
emailNotifications: settings.emailNotifications,
desktopNotifications: settings.desktopNotifications,
anonymousAnalytics: settings.anonymousAnalytics,
@@ -118,6 +186,7 @@ const getCachedAISettings = unstable_cache(
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
@@ -150,6 +219,7 @@ export async function getAISettings(userId?: string) {
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
notesViewMode: 'masonry' as const,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,

View File

@@ -0,0 +1,167 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { createHash, randomBytes } from 'crypto'
const KEY_PREFIX = 'mcp_key_'
function hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex')
}
export type McpKeyInfo = {
shortId: string
name: string
userId: string
userName: string
active: boolean
createdAt: string
lastUsedAt: string | null
}
/**
* List all MCP API keys for the current user.
*/
export async function listMcpKeys(): Promise<McpKeyInfo[]> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const allKeys = await prisma.systemConfig.findMany({
where: { key: { startsWith: KEY_PREFIX } },
})
const keys: McpKeyInfo[] = []
for (const entry of allKeys) {
try {
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) continue
keys.push({
shortId: info.shortId,
name: info.name,
userId: info.userId,
userName: info.userName,
active: info.active,
createdAt: info.createdAt,
lastUsedAt: info.lastUsedAt,
})
} catch {
// skip invalid JSON
}
}
return keys
}
/**
* Generate a new MCP API key for the current user.
* Returns the raw key (shown only once) and key info.
*/
export async function generateMcpKey(name: string): Promise<{ rawKey: string; info: { shortId: string; name: string } }> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true, name: true, email: true },
})
if (!user) throw new Error('User not found')
const rawBytes = randomBytes(24)
const shortId = rawBytes.toString('hex').substring(0, 8)
const rawKey = `mcp_sk_${rawBytes.toString('hex')}`
const keyHash = hashKey(rawKey)
const keyInfo = {
shortId,
name: name || `Key for ${user.name}`,
userId: user.id,
userName: user.name,
userEmail: user.email,
keyHash,
createdAt: new Date().toISOString(),
lastUsedAt: null,
active: true,
}
await prisma.systemConfig.create({
data: {
key: `${KEY_PREFIX}${shortId}`,
value: JSON.stringify(keyInfo),
},
})
revalidatePath('/settings/mcp')
return {
rawKey,
info: { shortId, name: keyInfo.name },
}
}
/**
* Revoke (deactivate) an MCP API key. Only the owner can revoke.
*/
export async function revokeMcpKey(shortId: string): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const configKey = `${KEY_PREFIX}${shortId}`
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
if (!entry) throw new Error('Key not found')
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) throw new Error('Forbidden')
if (!info.active) return false
info.active = false
info.revokedAt = new Date().toISOString()
await prisma.systemConfig.update({
where: { key: configKey },
data: { value: JSON.stringify(info) },
})
revalidatePath('/settings/mcp')
return true
}
/**
* Permanently delete an MCP API key. Only the owner can delete.
*/
export async function deleteMcpKey(shortId: string): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const configKey = `${KEY_PREFIX}${shortId}`
const entry = await prisma.systemConfig.findUnique({ where: { key: configKey } })
if (!entry) throw new Error('Key not found')
const info = JSON.parse(entry.value)
if (info.userId !== session.user.id) throw new Error('Forbidden')
try {
await prisma.systemConfig.delete({ where: { key: configKey } })
revalidatePath('/settings/mcp')
return true
} catch {
return false
}
}
export type McpServerStatus = {
mode: 'stdio' | 'sse' | 'unknown'
url: string | null
}
/**
* Get MCP server status — mode and URL.
*/
export async function getMcpServerStatus(): Promise<McpServerStatus> {
// Check if SSE mode is configured via env
const mode = process.env.MCP_SERVER_MODE === 'sse' ? 'sse' : 'stdio'
const url = process.env.MCP_SERVER_URL || null
return { mode, url }
}

View File

@@ -385,9 +385,9 @@ export async function createNote(data: {
reminder?: Date | null
isMarkdown?: boolean
size?: 'small' | 'medium' | 'large'
sharedWith?: string[]
autoGenerated?: boolean
notebookId?: string | undefined // Assign note to a notebook if provided
skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates
}) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
@@ -421,8 +421,10 @@ export async function createNote(data: {
await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
}
// Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/')
if (!data.skipRevalidation) {
// Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/')
}
// Fire-and-forget: run AI operations in background without blocking the response
const userId = session.user.id
@@ -470,7 +472,9 @@ export async function createNote(data: {
data: { labels: JSON.stringify(appliedLabels) }
})
await syncLabels(userId, appliedLabels, notebookId ?? null)
revalidatePath('/')
if (!data.skipRevalidation) {
revalidatePath('/')
}
}
}
} catch (error) {
@@ -503,7 +507,7 @@ export async function updateNote(id: string, data: {
size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean | null
notebookId?: string | null
}) {
}, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
@@ -556,9 +560,10 @@ export async function updateNote(id: string, data: {
// Only update contentUpdatedAt for actual content changes, NOT for property changes
// (size, color, isPinned, isArchived are properties, not content)
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
const isContentChange = contentFields.some(field => field in data)
if (isContentChange) {
if (isContentChange && !options?.skipContentTimestamp) {
updateData.contentUpdatedAt = new Date()
}
@@ -582,7 +587,7 @@ export async function updateNote(id: string, data: {
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
const isStructuralChange = structuralFields.some(field => field in data)
if (isStructuralChange) {
if (isStructuralChange && !options?.skipRevalidation) {
revalidatePath('/')
revalidatePath(`/note/${id}`)

View File

@@ -45,7 +45,7 @@ export async function POST(req: NextRequest) {
// Otherwise, use legacy auto-tagging (generates new tags)
const config = await getSystemConfig();
const provider = getAIProvider(config);
const tags = await provider.generateTags(content);
const tags = await provider.generateTags(content, language);
return NextResponse.json({ tags });
} catch (error: any) {

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { text, targetLanguage } = await request.json()
if (!text || !targetLanguage) {
return NextResponse.json({ error: 'text and targetLanguage are required' }, { status: 400 })
}
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const prompt = `Translate the following text to ${targetLanguage}. Return ONLY the translated text, no explanation, no preamble, no quotes:\n\n${text}`
const translatedText = await provider.generateText(prompt)
return NextResponse.json({ translatedText: translatedText.trim() })
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Translation failed' }, { status: 500 })
}
}

View File

@@ -101,10 +101,11 @@ export async function PUT(
const newName = name ? name.trim() : currentLabel.name
// For backward compatibility, update old label field in notes if renaming
if (name && name.trim() !== currentLabel.name && currentLabel.userId) {
const targetUserIdPut = currentLabel.userId || currentLabel.notebook?.userId || session.user.id;
if (name && name.trim() !== currentLabel.name && targetUserIdPut) {
const allNotes = await prisma.note.findMany({
where: {
userId: currentLabel.userId,
userId: targetUserIdPut,
labels: { not: null }
},
select: { id: true, labels: true }
@@ -197,10 +198,11 @@ export async function DELETE(
}
// For backward compatibility, remove from old label field in notes
if (label.userId) {
const targetUserIdDel = label.userId || label.notebook?.userId || session.user.id;
if (targetUserIdDel) {
const allNotes = await prisma.note.findMany({
where: {
userId: label.userId,
userId: targetUserIdDel,
labels: { not: null }
},
select: { id: true, labels: true }

View File

@@ -64,21 +64,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
}
}
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
setSettings(prev => ({ ...prev, aiProvider: value }))
try {
setIsPending(true)
await updateAISettings({ aiProvider: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating provider:', error)
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
setSettings(prev => ({ ...prev, preferredLanguage: value }))
@@ -188,54 +174,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
/>
</div>
{/* AI Provider Selection */}
<Card className="p-4">
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
<p className="text-sm text-gray-500 mb-4">
{t('aiSettings.providerDesc')}
</p>
<RadioGroup
value={settings.aiProvider}
onValueChange={handleProviderChange}
>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="auto" id="auto" />
<div className="grid gap-1.5">
<Label htmlFor="auto" className="font-medium">
{t('aiSettings.providerAuto')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerAutoDesc')}
</p>
</div>
</div>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="ollama" id="ollama" />
<div className="grid gap-1.5">
<Label htmlFor="ollama" className="font-medium">
{t('aiSettings.providerOllama')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerOllamaDesc')}
</p>
</div>
</div>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="openai" id="openai" />
<div className="grid gap-1.5">
<Label htmlFor="openai" className="font-medium">
{t('aiSettings.providerOpenAI')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerOpenAIDesc')}
</p>
</div>
</div>
</RadioGroup>
</Card>
</div>
)
}

View File

@@ -330,12 +330,12 @@ export function Header({
<div className="flex flex-1 justify-end gap-4 items-center">
{/* Settings Button */}
<button
onClick={() => router.push('/settings')}
<Link
href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
>
<Settings className="w-5 h-5" />
</button>
</Link>
{/* User Avatar Menu */}
<DropdownMenu>
@@ -356,13 +356,17 @@ export function Header({
{currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>}
</div>
</div>
<DropdownMenuItem onClick={() => router.push('/settings/profile')} className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') || 'Profile'}</span>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/profile">
<User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') || 'Profile'}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" />
<span>{t('nav.adminDashboard')}</span>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/admin">
<Shield className="mr-2 h-4 w-4" />
<span>{t('nav.adminDashboard')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" />

View File

@@ -24,7 +24,7 @@ interface LabelFilterProps {
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
const { labels, loading } = useLabels()
const { t } = useLanguage()
const { t, language } = useLanguage()
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
useEffect(() => {
@@ -47,10 +47,11 @@ export function LabelFilter({ selectedLabels, onFilterChange, className }: Label
if (loading || allLabelNames.length === 0) return null
return (
<div className={cn("flex items-center gap-2", className ? "" : "")}>
<DropdownMenu>
<div dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'} className={cn("flex items-center gap-2", className ? "" : "")}>
<DropdownMenu dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
<DropdownMenuTrigger asChild>
<Button
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
variant="outline"
size="sm"
className={cn(

View File

@@ -16,6 +16,7 @@ import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
export interface LabelManagementDialogProps {
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
@@ -26,7 +27,9 @@ export interface LabelManagementDialogProps {
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const { open, onOpenChange } = props
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const { t } = useLanguage()
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null)
@@ -37,6 +40,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
if (trimmed) {
try {
await addLabel(trimmed, 'gray')
triggerRefresh()
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
@@ -45,18 +49,19 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
}
const handleDeleteLabel = async (id: string) => {
if (confirm(t('labels.confirmDelete'))) {
try {
await deleteLabel(id)
} catch (error) {
console.error('Failed to delete label:', error)
}
try {
await deleteLabel(id)
triggerRefresh()
setConfirmDeleteId(null)
} catch (error) {
console.error('Failed to delete label:', error)
}
}
const handleChangeColor = async (id: string, color: LabelColorName) => {
try {
await updateLabel(id, { color })
triggerRefresh()
setEditingColorId(null)
} catch (error) {
console.error('Failed to update label color:', error)
@@ -157,26 +162,38 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setEditingColorId(isEditing ? null : label.id)}
title={t('labels.changeColor')}
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleDeleteLabel(label.id)}
title={t('labels.deleteTooltip')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{confirmDeleteId === label.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 font-medium">{t('labels.confirmDeleteShort') || 'Confirmer ?'}</span>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setConfirmDeleteId(null)}>
{t('common.cancel') || 'Annuler'}
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={() => handleDeleteLabel(label.id)}>
{t('common.delete') || 'Supprimer'}
</Button>
</div>
) : (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setEditingColorId(isEditing ? null : label.id)}
title={t('labels.changeColor')}
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => setConfirmDeleteId(label.id)}
title={t('labels.deleteTooltip')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
})
@@ -188,14 +205,14 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
if (controlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
{dialogContent}
</Dialog>
)
}
return (
<Dialog>
<Dialog dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" />

View File

@@ -0,0 +1,478 @@
'use client'
import { useState, useTransition } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import {
Info,
Key,
Server,
Plus,
Copy,
Check,
Trash2,
Ban,
Loader2,
ExternalLink,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import {
generateMcpKey,
revokeMcpKey,
deleteMcpKey,
type McpKeyInfo,
type McpServerStatus,
} from '@/app/actions/mcp-keys'
interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus
}
export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanelProps) {
const [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const { t } = useLanguage()
const handleGenerate = async (name: string) => {
startTransition(async () => {
try {
const result = await generateMcpKey(name)
setCreateOpen(false)
// Show the raw key in a new dialog
setShowRawKey(result.rawKey)
setRawKeyName(result.info.name)
// Refresh keys
setKeys(prev => [
{
shortId: result.info.shortId,
name: result.info.name,
userId: '',
userName: '',
active: true,
createdAt: new Date().toISOString(),
lastUsedAt: null,
},
...prev,
])
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to generate key')
}
})
}
const handleRevoke = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmRevoke'))) return
startTransition(async () => {
try {
await revokeMcpKey(shortId)
setKeys(prev =>
prev.map(k => (k.shortId === shortId ? { ...k, active: false } : k))
)
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to revoke key')
}
})
}
const handleDelete = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmDelete'))) return
startTransition(async () => {
try {
await deleteMcpKey(shortId)
setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete key')
}
})
}
// Raw key display state
const [showRawKey, setShowRawKey] = useState<string | null>(null)
const [rawKeyName, setRawKeyName] = useState('')
const [copied, setCopied] = useState(false)
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-6">
{/* Section 1: What is MCP */}
<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>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t('mcpSettings.whatIsMcp.description')}
</p>
<a
href="https://modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2"
>
{t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</Card>
{/* Section 2: Server Status */}
<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="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.mode')}:</span>
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.url')}:</span>
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
{serverStatus.url}
</code>
</div>
)}
</div>
</Card>
{/* Section 3: API Keys */}
<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>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5">
<Plus className="h-4 w-4" />
{t('mcpSettings.apiKeys.generate')}
</Button>
</DialogTrigger>
<CreateKeyDialog
onGenerate={handleGenerate}
isPending={isPending}
/>
</Dialog>
</div>
{keys.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('mcpSettings.apiKeys.empty')}</p>
</div>
) : (
<div className="space-y-3">
{keys.map(k => (
<KeyCard
key={k.shortId}
keyInfo={k}
onRevoke={handleRevoke}
onDelete={handleDelete}
isPending={isPending}
/>
))}
</div>
)}
</Card>
{/* Section 4: Configuration Instructions */}
<ConfigInstructions serverStatus={serverStatus} />
{/* Raw Key Display Dialog */}
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.successTitle')}</DialogTitle>
<DialogDescription>
{t('mcpSettings.createDialog.successDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-500">{rawKeyName}</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded break-all font-mono">
{showRawKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(showRawKey!)}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setShowRawKey(null)}>
{t('mcpSettings.createDialog.done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ── Sub-components ──────────────────────────────────────────────────────────────
function CreateKeyDialog({
onGenerate,
isPending,
}: {
onGenerate: (name: string) => void
isPending: boolean
}) {
const [name, setName] = useState('')
const { t } = useLanguage()
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.title')}</DialogTitle>
<DialogDescription>
{t('mcpSettings.createDialog.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="key-name">{t('mcpSettings.createDialog.nameLabel')}</Label>
<Input
id="key-name"
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => onGenerate(name)}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-1" />
{t('mcpSettings.createDialog.generating')}
</>
) : (
<>
<Key className="h-4 w-4 mr-1" />
{t('mcpSettings.createDialog.generate')}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
)
}
function KeyCard({
keyInfo,
onRevoke,
onDelete,
isPending,
}: {
keyInfo: McpKeyInfo
onRevoke: (shortId: string) => void
onDelete: (shortId: string) => void
isPending: boolean
}) {
const { t } = useLanguage()
const formatDate = (iso: string | null) => {
if (!iso) return t('mcpSettings.apiKeys.never')
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<div className="flex items-center justify-between p-4 rounded-lg border bg-gray-50 dark:bg-gray-900">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{keyInfo.name}</span>
<Badge variant={keyInfo.active ? 'default' : 'secondary'} className="text-xs">
{keyInfo.active
? t('mcpSettings.apiKeys.active')
: t('mcpSettings.apiKeys.revoked')}
</Badge>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<span>
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
</span>
<span>
{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}
</span>
</div>
</div>
<div>
{keyInfo.active ? (
<Button
size="sm"
variant="outline"
onClick={() => onRevoke(keyInfo.shortId)}
disabled={isPending}
className="gap-1"
>
<Ban className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.revoke')}
</Button>
) : (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(keyInfo.shortId)}
disabled={isPending}
className="gap-1"
>
<Trash2 className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.delete')}
</Button>
)}
</div>
</div>
)
}
function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus }) {
const { t } = useLanguage()
const [expanded, setExpanded] = useState<string | null>(null)
const baseUrl = serverStatus.url || 'http://localhost:3001'
const configs = [
{
id: 'claude-code',
title: t('mcpSettings.configInstructions.claudeCode.title'),
description: t('mcpSettings.configInstructions.claudeCode.description'),
snippet: JSON.stringify(
{
mcpServers: {
'keep-notes': {
command: 'node',
args: ['path/to/mcp-server/index.js'],
env: {
DATABASE_URL: 'file:path/to/keep-notes/prisma/dev.db',
APP_BASE_URL: 'http://localhost:3000',
},
},
},
},
null,
2
),
},
{
id: 'cursor',
title: t('mcpSettings.configInstructions.cursor.title'),
description: t('mcpSettings.configInstructions.cursor.description'),
snippet: JSON.stringify(
{
mcpServers: {
'keep-notes': {
url: baseUrl + '/mcp',
headers: {
'x-api-key': 'YOUR_API_KEY',
},
},
},
},
null,
2
),
},
{
id: 'n8n',
title: t('mcpSettings.configInstructions.n8n.title'),
description: t('mcpSettings.configInstructions.n8n.description'),
snippet: `MCP Server URL: ${baseUrl}/mcp
Header: x-api-key: YOUR_API_KEY
Transport: Streamable HTTP`,
},
]
return (
<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="space-y-2">
{configs.map(cfg => (
<div key={cfg.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
>
<span className="font-medium text-sm">{cfg.title}</span>
{expanded === cfg.id ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{expanded === cfg.id && (
<div className="px-4 pb-4">
<p className="text-sm text-gray-500 mb-2">{cfg.description}</p>
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
<code>{cfg.snippet}</code>
</pre>
</div>
)}
</div>
))}
</div>
</Card>
)
}

View File

@@ -169,7 +169,10 @@ export const NoteCard = memo(function NoteCard({
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
const colorClasses = NOTE_COLORS[optimisticNote.color as NoteColor] || NOTE_COLORS.default
// 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
@@ -263,10 +266,10 @@ export const NoteCard = memo(function NoteCard({
}
const handleColorChange = async (color: string) => {
setLocalColor(color) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ color })
await updateColor(note.id, color)
router.refresh()
await updateNote(note.id, { color }, { skipRevalidation: false })
})
}

View File

@@ -142,10 +142,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
}
// Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur
// (On garde celles déjà ajoutées pour les afficher en mode "validé")
// ni celles déjà présentes sur la note
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !dismissedTags.includes(s.tag)
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
})
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -0,0 +1,896 @@
'use client'
import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { LabelBadge } from '@/components/label-badge'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
updateNote,
togglePin,
toggleArchive,
updateColor,
deleteNote,
} from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import {
Pin,
Palette,
Archive,
ArchiveRestore,
Trash2,
ImageIcon,
Link as LinkIcon,
X,
Plus,
CheckSquare,
FileText,
Eye,
Sparkles,
Loader2,
Check,
Wand2,
AlignLeft,
Minimize2,
Lightbulb,
RotateCcw,
Languages,
ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content'
import { EditorImages } from '@/components/editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from '@/components/ghost-tags'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { TitleSuggestions } from '@/components/title-suggestions'
import { useLabels } from '@/context/LabelContext'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NoteInlineEditorProps {
note: Note
onDelete?: (noteId: string) => void
onArchive?: (noteId: string) => void
onChange?: (noteId: string, fields: Partial<Note>) => void
colorKey: NoteColor
/** If true and the note is a Markdown note, open directly in preview mode */
defaultPreviewMode?: boolean
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
async function saveInline(
id: string,
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean }
) {
await fetch(`/api/notes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export function NoteInlineEditor({
note,
onDelete,
onArchive,
onChange,
colorKey,
defaultPreviewMode = false,
}: NoteInlineEditorProps) {
const { t, language } = useLanguage()
const { labels: globalLabels, addLabel } = useLabels()
const [, startTransition] = useTransition()
// ── Local edit state ──────────────────────────────────────────────────────
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '')
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false)
)
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
// Link dialog
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const [isAddingLink, setIsAddingLink] = useState(false)
// AI popover
const [aiOpen, setAiOpen] = useState(false)
const [isProcessingAI, setIsProcessingAI] = useState(false)
// Undo after AI: saves content before transformation
const [previousContent, setPreviousContent] = useState<string | null>(null)
// Translate sub-panel
const [showTranslate, setShowTranslate] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
const noteIdRef = useRef(note.id)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '',
enabled: note.type === 'text' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown }
}, [title, content, checkItems, isMarkdown])
// ── Sync when selected note switches ─────────────────────────────────────
useEffect(() => {
// Flush unsaved changes for the PREVIOUS note before switching
if (isDirty && noteIdRef.current !== note.id) {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
noteIdRef.current = note.id
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
setIsDirty(false)
setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id])
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
const scheduleSave = useCallback(() => {
setIsDirty(true)
clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
setIsSaving(true)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
})
setIsDirty(false)
} catch {
// silent — retry on next keystroke
} finally {
setIsSaving(false)
}
}, 1500)
}, [note.type])
// Flush on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text',
})
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(
(s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
)
const handleSelectGhostTag = async (tag: string) => {
const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase())
if (!exists) {
const newLabels = [...(note.labels || []), tag]
// Optimistic UI — update sidebar immediately, no page refresh needed
onChange?.(note.id, { labels: newLabels })
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try { await addLabel(tag) } catch {}
}
toast.success(t('ai.tagAdded', { tag }))
}
}
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
const handleTogglePin = () => {
startTransition(async () => {
// Optimitistic update
onChange?.(note.id, { isPinned: !note.isPinned })
// Call with skipRevalidation to avoid server layout refresh interfering with optimistic state
await updateNote(note.id, { isPinned: !note.isPinned }, { skipRevalidation: true })
toast.success(note.isPinned ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée')
})
}
const handleToggleArchive = () => {
startTransition(async () => {
onArchive?.(note.id)
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
})
}
const handleColorChange = (color: string) => {
startTransition(async () => {
onChange?.(note.id, { color })
await updateNote(note.id, { color }, { skipRevalidation: true })
})
}
const handleDelete = () => {
if (!confirm(t('notes.confirmDelete'))) return
startTransition(async () => {
await deleteNote(note.id)
onDelete?.(note.id)
})
}
// ── Image upload ──────────────────────────────────────────────────────────
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) throw new Error('Upload failed')
const data = await res.json()
const newImages = [...(note.images || []), data.url]
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
} catch {
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleRemoveImage = async (index: number) => {
const newImages = (note.images || []).filter((_, i) => i !== index)
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
}
// ── Link ──────────────────────────────────────────────────────────────────
const handleAddLink = async () => {
if (!linkUrl) return
setIsAddingLink(true)
try {
const metadata = await fetchLinkMetadata(linkUrl)
const newLink = metadata || { url: linkUrl, title: linkUrl }
const newLinks = [...(note.links || []), newLink]
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
toast.success(t('notes.linkAdded'))
} catch {
toast.error(t('notes.linkAddFailed'))
} finally {
setLinkUrl('')
setShowLinkInput(false)
setIsAddingLink(false)
}
}
const handleRemoveLink = async (index: number) => {
const newLinks = (note.links || []).filter((_, i) => i !== index)
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
}
// ── AI actions (called from Popover in toolbar) ───────────────────────────
const callAI = async (option: 'clarify' | 'shorten' | 'improve') => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) {
toast.error(t('ai.reformulationMinWords', { count: wc }))
return
}
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content) // save for undo
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to reformulate')
changeContent(data.reformulatedText || data.text)
scheduleSave()
toast.success(t('ai.reformulationApplied'))
} catch {
toast.error(t('ai.reformulationFailed'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const callTranslate = async (targetLanguage: string) => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 3) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, targetLanguage }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Translation failed')
changeContent(data.translatedText)
scheduleSave()
toast.success(t('ai.translationApplied') || `Traduit en ${targetLanguage}`)
} catch {
toast.error(t('ai.translationFailed') || 'Traduction échouée')
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
changeContent(data.transformedText)
setIsMarkdown(true)
scheduleSave()
toast.success(t('ai.transformSuccess'))
} catch {
toast.error(t('ai.transformError'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
// ── Checklist helpers ─────────────────────────────────────────────────────
const handleToggleCheckItem = (id: string) => {
const updated = checkItems.map((ci) =>
ci.id === id ? { ...ci, checked: !ci.checked } : ci
)
setCheckItems(updated)
scheduleSave()
}
const handleUpdateCheckText = (id: string, text: string) => {
const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci))
setCheckItems(updated)
scheduleSave()
}
const handleAddCheckItem = () => {
const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }]
setCheckItems(updated)
scheduleSave()
}
const handleRemoveCheckItem = (id: string) => {
const updated = checkItems.filter((ci) => ci.id !== id)
setCheckItems(updated)
scheduleSave()
}
const dateLocale = getDateLocale(language)
return (
<div className="flex h-full flex-col overflow-hidden">
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
<div className="flex items-center gap-1">
{/* Image upload */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addImage') || 'Ajouter une image'}
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="h-4 w-4" />
</Button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
{/* Link */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addLink') || 'Ajouter un lien'}
onClick={() => setShowLinkInput(!showLinkInput)}
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Markdown toggle */}
<Button
variant="ghost" size="sm"
className={cn('h-8 gap-1 px-2 text-xs', isMarkdown && 'text-primary')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
title="Markdown"
>
<FileText className="h-3.5 w-3.5" />
<span className="hidden sm:inline">MD</span>
</Button>
{isMarkdown && (
<Button
variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
>
<Eye className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{showMarkdownPreview ? t('notes.edit') || 'Éditer' : t('notes.preview') || 'Aperçu'}</span>
</Button>
)}
{/* ── AI Popover (in toolbar, non-intrusive) ─────────────────────── */}
{note.type === 'text' && (
<Popover open={aiOpen} onOpenChange={(o) => { setAiOpen(o); if (!o) setShowTranslate(false) }}>
<PopoverTrigger asChild>
<Button
variant="ghost" size="sm"
className={cn(
'h-8 gap-1.5 px-2 text-xs transition-colors',
isProcessingAI && 'text-primary',
aiOpen && 'bg-muted text-primary',
)}
disabled={isProcessingAI}
title="Assistant IA"
>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />
}
<span className="hidden sm:inline">IA</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{!showTranslate ? (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('clarify')}
>
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.clarify') || 'Clarifier'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.clarifyDesc') || 'Rendre plus clair'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('shorten')}
>
<Minimize2 className="h-4 w-4 text-blue-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.shorten') || 'Raccourcir'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.shortenDesc') || 'Version concise'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('improve')}
>
<AlignLeft className="h-4 w-4 text-emerald-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.improve') || 'Améliorer'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.improveDesc') || 'Meilleure rédaction'}</p>
</div>
</button>
<button type="button"
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left w-full"
onClick={() => setShowTranslate(true)}
>
<div className="flex items-center gap-2">
<Languages className="h-4 w-4 text-sky-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.translate') || 'Traduire'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.translateDesc') || 'Changer la langue'}</p>
</div>
</div>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</button>
<div className="my-0.5 border-t border-border/40" />
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={handleTransformMarkdown}
>
<Wand2 className="h-4 w-4 text-violet-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.toMarkdown') || 'En Markdown'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.toMarkdownDesc') || 'Formater en MD'}</p>
</div>
</button>
</div>
) : (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowTranslate(false)}
>
<RotateCcw className="h-3 w-3" />
{t('ai.translateBack') || 'Retour'}
</button>
<div className="my-0.5 border-t border-border/40" />
{[
{ code: 'French', label: 'Français 🇫🇷' },
{ code: 'English', label: 'English 🇬🇧' },
{ code: 'Persian', label: 'فارسی 🇮🇷' },
{ code: 'Spanish', label: 'Español 🇪🇸' },
{ code: 'German', label: 'Deutsch 🇩🇪' },
{ code: 'Italian', label: 'Italiano 🇮🇹' },
{ code: 'Portuguese', label: 'Português 🇵🇹' },
{ code: 'Arabic', label: 'العربية 🇸🇦' },
{ code: 'Chinese', label: '中文 🇨🇳' },
{ code: 'Japanese', label: '日本語 🇯🇵' },
].map(({ code, label }) => (
<button key={code} type="button"
className="w-full rounded-md px-3 py-1.5 text-sm hover:bg-muted text-left"
onClick={() => callTranslate(code)}
>
{label}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
)}
{/* ── Undo AI button ─────────────────────────────────────────────── */}
{previousContent !== null && (
<Button
variant="ghost" size="sm"
className="h-8 gap-1.5 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
title={t('ai.undoAI') || 'Annuler transformation IA'}
onClick={() => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
toast.info(t('ai.undoApplied') || 'Texte original restauré')
}}
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('ai.undo') || 'Annuler IA'}</span>
</Button>
)}
</div>
<div className="flex items-center gap-1">
{/* Save status indicator */}
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Sauvegarde</>
) : isDirty ? (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> Modifié</>
) : (
<><Check className="h-3 w-3 text-emerald-500" /> Sauvegardé</>
)}
</span>
{/* Pin */}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
</Button>
{/* Color picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
<button type="button"
key={name}
className={cn(
'h-7 w-7 rounded-full border-2 transition-transform hover:scale-110',
cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => handleColorChange(name)}
title={name}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* More actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.moreOptions')}>
<span className="text-base leading-none text-muted-foreground"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
{showLinkInput && (
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
<input
type="url"
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
autoFocus
/>
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ajouter'}
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
{/* Existing labels */}
{(note.labels ?? []).map((label) => (
<LabelBadge key={label} label={label} />
))}
{/* AI-suggested tags inline with labels */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={note.labels || []}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
/>
</div>
)}
{/* ── Scrollable editing area (takes all remaining height) ─────────── */}
<div className="flex flex-1 flex-col overflow-y-auto px-8 py-5">
{/* Title row with optional AI suggest button */}
<div className="group relative flex items-start gap-2 shrink-0">
<input
type="text"
dir="auto"
className="flex-1 bg-transparent text-2xl font-bold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
/>
{/* AI title suggestion — show when title is empty and there's content */}
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
onClick={async (e) => {
e.preventDefault()
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/suggest-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
if (res.ok) {
const data = await res.json()
const suggested = data.title || data.suggestedTitle || ''
if (suggested) { changeTitle(suggested); scheduleSave() }
}
} catch { /* silent */ } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="mt-1.5 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title="Suggestion de titre par IA"
>
{isProcessingAI
? <Loader2 className="h-4 w-4 animate-spin" />
: <Sparkles className="h-4 w-4" />}
</button>
)}
</div>
{/* Title Suggestions Dropdown / Inline list */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="mt-2 text-sm shrink-0">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</div>
)}
{/* Images */}
{note.images && note.images.length > 0 && (
<div className="mt-4">
<EditorImages images={note.images} onRemove={handleRemoveImage} />
</div>
)}
{/* Link previews */}
{note.links && note.links.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{note.links.map((link, idx) => (
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
{link.imageUrl && (
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col">
{note.type === 'text' ? (
<div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
<MarkdownContent content={content || ''} />
</div>
) : (
<textarea
dir="auto"
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={isMarkdown
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
: t('notes.takeNote') || 'Écris quelque chose…'
}
value={content}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
style={{ minHeight: '200px' }}
/>
)}
{/* Ghost tag suggestions are now shown in the top labels strip */}
</div>
) : (
/* Checklist */
<div className="space-y-1">
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
onClick={() => handleToggleCheckItem(ci.id)}
/>
<input
dir="auto"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
value={ci.text}
placeholder={t('notes.listItem') || 'Élément…'}
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
/>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
</div>
))}
<button type="button"
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
onClick={handleAddCheckItem}
>
<Plus className="h-4 w-4" />
{t('notes.addItem') || 'Ajouter un élément'}
</button>
{checkItems.filter((ci) => ci.checked).length > 0 && (
<div className="mt-3">
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
Complétés ({checkItems.filter((ci) => ci.checked).length})
</p>
{checkItems.filter((ci) => ci.checked).map((ci) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
onClick={() => handleToggleCheckItem(ci.id)}
>
<CheckSquare className="h-3 w-3 opacity-60" />
</button>
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span>{t('notes.modified') || 'Modifiée'} {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>
</div>
)
}

View File

@@ -68,9 +68,16 @@ interface NoteInputProps {
onNoteCreated?: (note: Note) => void
defaultExpanded?: boolean
forceExpanded?: boolean
/** Mode onglets : occupe toute la largeur du contenu principal (plus de carte étroite centrée) */
fullWidth?: boolean
}
export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpanded = false }: NoteInputProps) {
export function NoteInput({
onNoteCreated,
defaultExpanded = false,
forceExpanded = false,
fullWidth = false,
}: NoteInputProps) {
const { labels: globalLabels, addLabel } = useLabels()
const { data: session } = useSession()
const { t } = useLanguage()
@@ -109,7 +116,8 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded
enabled: type === 'text' && isExpanded,
notebookId: currentNotebookId
})
// Title suggestions
@@ -559,11 +567,13 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
setDismissedTitleSuggestions(false)
}
const widthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
if (!isExpanded) {
return (
<Card className="p-4 max-w-2xl mx-auto mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow">
<Card className={cn('p-4 mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow', widthClass)}>
<div className="flex items-center gap-4">
<Input
<Input dir="auto"
placeholder={t('notes.placeholder')}
onClick={() => setIsExpanded(true)}
readOnly
@@ -590,12 +600,9 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
return (
<>
<Card className={cn(
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
colorClasses.card
)}>
<Card className={cn('p-4 mb-8 shadow-lg border', widthClass, colorClasses.card)}>
<div className="space-y-3">
<Input
<Input dir="auto"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -707,7 +714,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/>
) : (
<Textarea
<Textarea dir="auto"
placeholder={isMarkdown ? t('notes.markdownPlaceholder') : t('notes.placeholder')}
value={content}
onChange={(e) => setContent(e.target.value)}
@@ -743,7 +750,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
{checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox className="mt-2" />
<Input
<Input dir="auto"
value={item.text}
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
@@ -1015,7 +1022,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<label htmlFor="reminder-date" className="text-sm font-medium">
{t('notes.date')}
</label>
<Input
<Input dir="auto"
id="reminder-date"
type="date"
value={reminderDate}
@@ -1027,7 +1034,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<label htmlFor="reminder-time" className="text-sm font-medium">
{t('notes.time')}
</label>
<Input
<Input dir="auto"
id="reminder-time"
type="time"
value={reminderTime}
@@ -1077,7 +1084,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<DialogTitle>{t('notes.addLink')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
<Input dir="auto"
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}

View File

@@ -44,7 +44,7 @@ export function NotebooksList() {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
@@ -160,7 +160,7 @@ export function NotebooksList() {
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave}
className={cn(
"flex flex-col mr-2 rounded-r-full transition-all relative",
"flex flex-col me-2 rounded-e-full transition-all relative",
!notebook.color && "bg-primary/10 dark:bg-primary/20",
isDragOver && "ring-2 ring-primary ring-dashed"
)}
@@ -211,7 +211,7 @@ export function NotebooksList() {
{isExpanded && (
<div className="flex flex-col pb-2">
{labels.length === 0 ? (
<p className="pointer-events-none pl-12 pr-4 py-2 text-xs text-muted-foreground">
<p className="pointer-events-none ps-12 pe-4 py-2 text-xs text-muted-foreground">
{t('sidebar.noLabelsInNotebook')}
</p>
) : (
@@ -221,7 +221,7 @@ export function NotebooksList() {
type="button"
onClick={() => handleLabelFilter(label.name, notebook.id)}
className={cn(
'pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 rounded-r-full mr-2 transition-colors',
'pointer-events-auto flex items-center gap-4 ps-12 pe-4 py-2 rounded-e-full me-2 transition-colors',
'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
searchParams.get('labels')?.includes(label.name) &&
'font-semibold text-foreground'
@@ -235,7 +235,7 @@ export function NotebooksList() {
<button
type="button"
onClick={() => setLabelsDialogOpen(true)}
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 rounded-r-full mr-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
className="pointer-events-auto flex items-center gap-2 ps-12 pe-4 py-2 mt-1 rounded-e-full me-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
>
<Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
<span className="text-xs font-medium">{t('sidebar.editLabels')}</span>
@@ -251,25 +251,25 @@ export function NotebooksList() {
onDragLeave={handleDragLeave}
className={cn(
"flex items-center relative",
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-r-full mr-2"
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
)}
>
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pr-24",
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-24",
isDragOver && "opacity-50"
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-left">{notebook.name}</span>
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ml-2 flex-shrink-0">({(notebook as any).notesCount})</span>
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}
</button>
{/* Actions + expand on the right — always rendered, visible on hover */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<div className="absolute end-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<NotebookActions
notebook={notebook}
onEdit={() => setEditingNotebook(notebook)}

View File

@@ -0,0 +1,43 @@
'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
currentNotebookId?: string | null
}
export function NotesMainSection({ notes, viewMode, onEdit, 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} />
</div>
)
}

View File

@@ -0,0 +1,435 @@
'use client'
import { useCallback, useEffect, useState, useTransition } from 'react'
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { updateFullOrderWithoutRevalidation, createNote } from '@/app/actions/notes'
import {
GripVertical,
Hash,
ListChecks,
Pin,
FileText,
Clock,
Plus,
Loader2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
currentNotebookId?: string | null
}
// Color accent strip for each note
const COLOR_ACCENT: Record<NoteColor, string> = {
default: 'bg-primary',
red: 'bg-red-400',
orange: 'bg-orange-400',
yellow: 'bg-amber-400',
green: 'bg-emerald-400',
teal: 'bg-teal-400',
blue: 'bg-sky-400',
purple: 'bg-violet-400',
pink: 'bg-fuchsia-400',
gray: 'bg-gray-400',
}
// Background tint gradient for selected note panel
const COLOR_PANEL_BG: Record<NoteColor, string> = {
default: 'from-background to-background',
red: 'from-red-50/60 dark:from-red-950/20 to-background',
orange: 'from-orange-50/60 dark:from-orange-950/20 to-background',
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
}
const COLOR_ICON: Record<NoteColor, string> = {
default: 'text-primary',
red: 'text-red-500',
orange: 'text-orange-500',
yellow: 'text-amber-500',
green: 'text-emerald-500',
teal: 'text-teal-500',
blue: 'text-sky-500',
purple: 'text-violet-500',
pink: 'text-fuchsia-500',
gray: 'text-gray-500',
}
function getColorKey(note: Note): NoteColor {
return (typeof note.color === 'string' && note.color in NOTE_COLORS
? note.color
: 'default') as NoteColor
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
function SortableNoteListItem({
note,
selected,
onSelect,
reorderLabel,
language,
untitledLabel,
}: {
note: Note
selected: boolean
onSelect: () => void
reorderLabel: string
language: string
untitledLabel: string
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: note.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
}
const ck = getColorKey(note)
const title = getNoteDisplayTitle(note, untitledLabel)
const snippet =
note.type === 'checklist'
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
: (note.content || '').substring(0, 150)
const dateLocale = getDateLocale(language)
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: dateLocale,
})
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch gap-0 rounded-xl transition-all duration-150',
'border',
selected
? 'border-primary/20 bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'border-transparent hover:border-border/60 hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Color accent bar */}
<div
className={cn(
'w-1 shrink-0 rounded-s-xl transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
)}
/>
{/* Drag handle */}
<button
type="button"
className="flex cursor-grab items-center px-1.5 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
{/* Note type icon */}
<div className="flex items-center py-4 pe-1">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
) : (
<FileText
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
)}
</div>
{/* Text content */}
<div className="min-w-0 flex-1 py-3.5 pe-3">
<div className="flex items-center gap-2">
<p
className={cn(
'truncate text-sm font-medium transition-colors',
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
)}
>
{title}
</p>
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary" aria-label="Épinglée" />
)}
</div>
{snippet && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">{snippet}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
<Clock className="h-2.5 w-2.5" />
{timeAgo}
</span>
{note.labels && note.labels.length > 0 && (
<>
<span className="text-muted-foreground/30">·</span>
<div className="flex items-center gap-1">
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
<span className="truncate text-[11px] text-muted-foreground/50">
{note.labels.slice(0, 2).join(', ')}
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
</span>
</div>
</>
)}
</div>
</div>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
const { t, language } = useLanguage()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
useEffect(() => {
// Only reset when notes are added or removed, NOT on content/field changes
// Field changes arrive through onChange -> setItems already
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
if (prevIds === incomingIds) {
// Same set of notes: merge only structural fields (pin, color, archive)
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p
return { ...fresh, title: p.title, content: p.content, labels: p.labels }
})
}
// Different set (add/remove): full sync
return notes
})
}, [notes])
useEffect(() => {
if (items.length === 0) {
setSelectedId(null)
return
}
setSelectedId((prev) =>
prev && items.some((n) => n.id === prev) ? prev : items[0].id
)
}, [items])
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((n) => n.id === active.id)
const newIndex = items.findIndex((n) => n.id === over.id)
if (oldIndex < 0 || newIndex < 0) return
const reordered = arrayMove(items, oldIndex, newIndex)
setItems(reordered)
try {
await updateFullOrderWithoutRevalidation(reordered.map((n) => n.id))
} catch {
setItems(notes)
toast.error(t('notes.moveFailed'))
}
},
[items, notes, t]
)
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
/** Create a new blank note, add it to the sidebar and select it immediately */
const handleCreateNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
title: null,
notebookId: currentNotebookId || null,
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => [newNote, ...prev])
setSelectedId(newNote.id)
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
}
})
}
if (items.length === 0) {
return (
<div
className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-12 text-center"
data-testid="notes-grid-tabs-empty"
>
<p className="max-w-md text-sm text-muted-foreground">{t('notes.emptyStateTabs')}</p>
</div>
)
}
return (
<div
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-2xl border border-border/60 shadow-sm"
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left sidebar: note list ── */}
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
{/* Sidebar header with note count + new note button */}
<div className="border-b border-border/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
{t('notes.title')}
<span className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{items.length}
</span>
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote') || 'Nouvelle note'}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain p-2"
role="listbox"
aria-label={t('notes.viewTabs')}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
reorderLabel={t('notes.reorderTabs')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
{/* ── Right content panel — always in edit mode ── */}
{selected ? (
<div
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
)
}}
onDelete={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
/>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground/40">
<p className="text-sm">{t('notes.selectNote') || 'Sélectionnez une note'}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useTransition } from 'react'
import { LayoutGrid, PanelsTopLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { updateAISettings } from '@/app/actions/ai-settings'
import { useLanguage } from '@/lib/i18n'
import type { NotesViewMode } from '@/components/notes-main-section'
interface NotesViewToggleProps {
mode: NotesViewMode
onModeChange: (mode: NotesViewMode) => void
className?: string
}
export function NotesViewToggle({ mode, onModeChange, className }: NotesViewToggleProps) {
const { t, language } = useLanguage()
const [pending, startTransition] = useTransition()
const setMode = (next: NotesViewMode) => {
if (next === mode) return
const previous = mode
onModeChange(next)
startTransition(async () => {
try {
await updateAISettings({ notesViewMode: next })
} catch {
onModeChange(previous)
}
})
}
return (
<TooltipProvider delayDuration={300}>
<div
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
className={cn(
'inline-flex rounded-full border border-border bg-muted/40 p-0.5 shadow-sm',
className
)}
role="group"
aria-label={t('notes.viewModeGroup')}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'masonry' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('masonry')}
aria-pressed={mode === 'masonry'}
>
<LayoutGrid className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewCards')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'tabs' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('tabs')}
aria-pressed={mode === 'tabs'}
>
<PanelsTopLeft className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewTabs')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewTabsTooltip')}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)
}

View File

@@ -5,6 +5,7 @@ import { LabelProvider } from '@/context/LabelContext'
import { NotebooksProvider } from '@/context/notebooks-context'
import { NotebookDragProvider } from '@/context/notebook-drag-context'
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
import { HomeViewProvider } from '@/context/home-view-context'
import type { ReactNode } from 'react'
interface ProvidersWrapperProps {
@@ -19,7 +20,7 @@ export function ProvidersWrapper({ children, initialLanguage = 'en' }: Providers
<NotebooksProvider>
<NotebookDragProvider>
<LanguageProvider initialLanguage={initialLanguage as any}>
{children}
<HomeViewProvider>{children}</HomeViewProvider>
</LanguageProvider>
</NotebookDragProvider>
</NotebooksProvider>

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@@ -52,6 +52,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
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'),

View File

@@ -8,14 +8,25 @@ import {
Bell,
Archive,
Trash2,
Plus,
Sparkles,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useLanguage } from '@/lib/i18n'
import { NotebooksList } from './notebooks-list'
import { useHomeViewOptional } from '@/context/home-view-context'
export function Sidebar({ className, user }: { className?: string, user?: any }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const { t } = useLanguage()
const homeBridge = useHomeViewOptional()
// Helper to determine if a link is active
const isActive = (href: string, exact = false) => {
@@ -43,7 +54,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
<Link
href={href}
className={cn(
"flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 transition-colors",
"flex items-center gap-4 px-6 py-3 rounded-e-full me-2 transition-colors",
"text-sm font-medium tracking-wide",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
@@ -61,7 +72,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
className
)}>
{/* Main Navigation */}
<div className="flex flex-col">
<div className="flex flex-col gap-1 px-3">
<NavItem
href="/"
icon={Lightbulb}
@@ -74,6 +85,26 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
label={t('sidebar.reminders') || 'Rappels'}
active={isActive('/reminders')}
/>
{pathname === '/' && homeBridge?.controls?.isTabsMode && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
className="w-full justify-start gap-3 rounded-e-full ps-4 pe-3 font-medium shadow-sm"
onClick={() => homeBridge.controls?.openNoteComposer()}
>
<Plus className="h-5 w-5 shrink-0" />
<span className="truncate">{t('sidebar.newNoteTabs')}</span>
<Sparkles className="ml-auto h-4 w-4 shrink-0 text-primary" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px]">
{t('sidebar.newNoteTabsHint')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Notebooks Section */}

View File

@@ -22,6 +22,7 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
<span>{t('titleSuggestions.title')}</span>
</div>
<button
type="button"
onClick={onDismiss}
className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors"
>
@@ -33,6 +34,7 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
{suggestions.map((suggestion, index) => (
<button
key={index}
type="button"
onClick={() => onSelect(suggestion.title)}
className={cn(
"w-full text-left px-3 py-2 rounded-md transition-all",

View File

@@ -0,0 +1,36 @@
'use client'
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
export type HomeUiControls = {
isTabsMode: boolean
openNoteComposer: () => void
}
type Ctx = {
controls: HomeUiControls | null
setControls: (c: HomeUiControls | null) => void
}
const HomeViewContext = createContext<Ctx | null>(null)
export function HomeViewProvider({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<HomeUiControls | null>(null)
const value = useMemo(() => ({ controls, setControls }), [controls])
return <HomeViewContext.Provider value={value}>{children}</HomeViewContext.Provider>
}
/** Enregistré par la page daccueil ; la sidebar lit `controls` */
export function useHomeView() {
const ctx = useContext(HomeViewContext)
if (!ctx) {
throw new Error('useHomeView must be used within HomeViewProvider')
}
return ctx
}
/** Sidebar / shells : ne pas planter si hors provider */
export function useHomeViewOptional(): Ctx | null {
return useContext(HomeViewContext)
}

View File

@@ -1,3 +1,4 @@
import { useLanguage } from '@/lib/i18n'
import { useState, useEffect, useRef } from 'react';
import { useDebounce } from './use-debounce';
import { TagSuggestion } from '@/lib/ai/types';
@@ -9,6 +10,7 @@ interface UseAutoTaggingProps {
}
export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoTaggingProps) {
const { language } = useLanguage();
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -42,7 +44,7 @@ export function useAutoTagging({ content, notebookId, enabled = true }: UseAutoT
body: JSON.stringify({
content: contentToAnalyze,
notebookId: notebookId || undefined,
language: document.documentElement.lang || 'en',
language: language || document.documentElement.lang || 'en',
}),
});

View File

@@ -15,24 +15,40 @@ export class OllamaProvider implements AIProvider {
this.embeddingModelName = embeddingModelName || modelName;
}
async generateTags(content: string): Promise<TagSuggestion[]> {
async generateTags(content: string, language: string = "en"): Promise<TagSuggestion[]> {
try {
const promptText = language === 'fa'
? `متن زیر را تحلیل کن و مفاهیم کلیدی را به عنوان برچسب استخراج کن (حداکثر ۱-۳ کلمه).
قوانین:
- کلمات ربط را حذف کن.
- عبارات ترکیبی را حفظ کن.
- حداکثر ۵ برچسب.
پاسخ فقط به صورت لیست JSON با فرمت [{"tag": "string", "confidence": number}]
متن: "${content}"`
: language === 'fr'
? `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
Règles:
- Pas de mots de liaison.
- Garde les expressions composées ensemble.
- Normalise en minuscules sauf noms propres.
- Maximum 5 tags.
Réponds UNIQUEMENT sous forme de liste JSON d'objets : [{"tag": "string", "confidence": number}].
Contenu de la note: "${content}"`
: `Analyze the following note and extract key concepts as short tags (1-3 words max).
Rules:
- No stop words.
- Keep compound expressions together.
- Lowercase unless proper noun.
- Max 5 tags.
Respond ONLY as a JSON list of objects: [{"tag": "string", "confidence": number}].
Note content: "${content}"`;
const response = await fetch(`${this.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: `Analyse la note suivante et extrais les concepts clés sous forme de tags courts (1-3 mots max).
Règles:
- Pas de mots de liaison (le, la, pour, et...).
- Garde les expressions composées ensemble (ex: "semaine prochaine", "New York").
- Normalise en minuscules sauf noms propres.
- Maximum 5 tags.
Réponds UNIQUEMENT sous forme de liste JSON d'objets : [{"tag": "string", "confidence": number}].
Contenu de la note: "${content}"`,
prompt: promptText,
stream: false,
}),
});
@@ -88,9 +104,7 @@ export class OllamaProvider implements AIProvider {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.modelName,
prompt: `${prompt}
Réponds UNIQUEMENT sous forme de tableau JSON : [{"title": "string", "confidence": number}]`,
prompt: `${prompt}\n\nRéponds UNIQUEMENT sous forme de tableau JSON : [{"title": "string", "confidence": number}]`,
stream: false,
}),
});

View File

@@ -12,7 +12,7 @@ export interface AIProvider {
/**
* Analyse le contenu et suggère des tags pertinents.
*/
generateTags(content: string): Promise<TagSuggestion[]>;
generateTags(content: string, language?: string): Promise<TagSuggestion[]>;
/**
* Génère un vecteur d'embeddings pour la recherche sémantique.

View File

@@ -41,6 +41,8 @@ export interface Translations {
editLabels: string
archive: string
trash: string
newNoteTabs: string
newNoteTabsHint: string
}
notes: {
title: string
@@ -103,6 +105,7 @@ export interface Translations {
noNotes: string
noNotesFound: string
createFirstNote: string
emptyStateTabs: string
size: string
small: string
medium: string
@@ -126,6 +129,12 @@ export interface Translations {
markdownOff: string
undo: string
redo: string
viewCards: string
viewTabs: string
viewCardsTooltip: string
viewTabsTooltip: string
viewModeGroup: string
reorderTabs: string
}
pagination: {
previous: string
@@ -782,6 +791,10 @@ export interface Translations {
appearance: {
title: string
description: string
notesViewDescription: string
notesViewLabel: string
notesViewTabs: string
notesViewMasonry: string
}
generalSettings: {
title: string

View File

@@ -0,0 +1,33 @@
/**
* Plain-text preview for list view (light markdown stripping).
*/
export function stripMarkdownPreview(raw: string, maxLen = 180): string {
if (!raw?.trim()) return ''
let t = raw
.replace(/^#{1,6}\s+/gm, '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[(.+?)]\([^)]+\)/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/^\s*\d+\.\s+/gm, '')
.replace(/\n+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (t.length > maxLen) {
return `${t.slice(0, maxLen).trim()}`
}
return t
}
export function getNoteDisplayTitle(note: { title: string | null; content: string; type: string }, untitled: string): string {
const title = note.title?.trim()
if (title) return title
if (note.type === 'checklist') {
const line = note.content?.split('\n').find((l) => l.trim())
if (line) return stripMarkdownPreview(line, 80) || untitled
}
const preview = stripMarkdownPreview(note.content || '', 100)
return preview || untitled
}

View File

@@ -997,5 +997,65 @@
"collapse": "طي",
"expand": "توسيع",
"open": "فتح"
},
"mcpSettings": {
"title": "إعدادات MCP",
"description": "إدارة مفاتيح API وتكوين الأدوات الخارجية",
"whatIsMcp": {
"title": "ما هو MCP؟",
"description": "بروتوكول سياق النموذج (MCP) هو بروتوكول مفتوح يمكّن نماذج الذكاء الاصطناعي من التفاعل بأمان مع الأدوات ومصادر البيانات الخارجية. باستخدام MCP، يمكنك ربط أدوات مثل Claude Code و Cursor و N8N بمثيل Keep Notes الخاص بك لقراءة ملاحظاتك وإنشائها وتنظيمها برمجيًا.",
"learnMore": "معرفة المزيد عن MCP"
},
"serverStatus": {
"title": "حالة الخادم",
"running": "قيد التشغيل",
"stopped": "متوقف",
"mode": "الوضع",
"url": "URL"
},
"apiKeys": {
"title": "مفاتيح API",
"description": "تسمح مفاتيح API للأدوات الخارجية بالوصول إلى ملاحظاتك عبر MCP. حافظ على سرية مفاتيحك.",
"generate": "إنشاء مفتاح جديد",
"empty": "لا توجد مفاتيح API بعد. أنشئ واحدًا للبدء.",
"active": "نشط",
"revoked": "ملغى",
"revoke": "إلغاء",
"delete": "حذف",
"createdAt": "تاريخ الإنشاء",
"lastUsed": "آخر استخدام",
"never": "أبدًا",
"confirmRevoke": "هل أنت متأكد من إلغاء هذا المفتاح؟ ستفقد الأدوات التي تستخدمه صلاحية الوصول.",
"confirmDelete": "هل أنت متأكد من حذف هذا المفتاح نهائيًا؟"
},
"createDialog": {
"title": "إنشاء مفتاح API",
"description": "أنشئ مفتاح API جديدًا لربط الأدوات الخارجية بملاحظاتك.",
"nameLabel": "اسم المفتاح",
"namePlaceholder": "مثال: Claude Code، Cursor، N8N",
"generating": "جاري الإنشاء...",
"generate": "إنشاء",
"successTitle": "تم إنشاء مفتاح API",
"successDescription": "انسخ مفتاح API الآن. لن تتمكن من رؤيته مرة أخرى.",
"copy": "نسخ",
"copied": "تم النسخ!",
"done": "تم"
},
"configInstructions": {
"title": "تعليمات التكوين",
"description": "استخدم مفتاح API الخاص بك لتكوين هذه الأدوات.",
"claudeCode": {
"title": "Claude Code",
"description": "أضف هذا إلى ملف تكوين MCP الخاص بـ Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "أضف هذا إلى إعدادات MCP الخاصة بـ Cursor:"
},
"n8n": {
"title": "N8N",
"description": "استخدم بيانات الاعتماد هذه في عقدة N8N MCP:"
}
}
}
}

View File

@@ -997,5 +997,65 @@
"collapse": "Zusammenklappen",
"expand": "Erweitern",
"open": "Öffnen"
},
"mcpSettings": {
"title": "MCP-Einstellungen",
"description": "API-Schlüssel verwalten und externe Tools konfigurieren",
"whatIsMcp": {
"title": "Was ist MCP?",
"description": "Das Model Context Protocol (MCP) ist ein offenes Protokoll, das es KI-Modellen ermöglicht, sicher mit externen Tools und Datenquellen zu interagieren. Mit MCP können Sie Tools wie Claude Code, Cursor oder N8N mit Ihrer Keep Notes-Instanz verbinden, um Ihre Notes programmgesteuert zu lesen, zu erstellen und zu organisieren.",
"learnMore": "Mehr über MCP erfahren"
},
"serverStatus": {
"title": "Serverstatus",
"running": "Aktiv",
"stopped": "Gestoppt",
"mode": "Modus",
"url": "URL"
},
"apiKeys": {
"title": "API-Schlüssel",
"description": "API-Schlüssel ermöglichen externen Tools den Zugriff auf Ihre Notes über MCP. Halten Sie Ihre Schlüssel geheim.",
"generate": "Neuen Schlüssel generieren",
"empty": "Noch keine API-Schlüssel. Generieren Sie einen, um zu beginnen.",
"active": "Aktiv",
"revoked": "Widerrufen",
"revoke": "Widerrufen",
"delete": "Löschen",
"createdAt": "Erstellt",
"lastUsed": "Zuletzt verwendet",
"never": "Nie",
"confirmRevoke": "Sind Sie sicher, dass Sie diesen Schlüssel widerrufen möchten? Tools, die ihn verwenden, verlieren den Zugriff.",
"confirmDelete": "Sind Sie sicher, dass Sie diesen Schlüssel dauerhaft löschen möchten?"
},
"createDialog": {
"title": "API-Schlüssel generieren",
"description": "Erstellen Sie einen neuen API-Schlüssel, um externe Tools mit Ihren Notes zu verbinden.",
"nameLabel": "Schlüsselname",
"namePlaceholder": "z.B. Claude Code, Cursor, N8N",
"generating": "Wird generiert...",
"generate": "Generieren",
"successTitle": "API-Schlüssel generiert",
"successDescription": "Kopieren Sie Ihren API-Schlüssel jetzt. Sie können ihn später nicht mehr einsehen.",
"copy": "Kopieren",
"copied": "Kopiert!",
"done": "Fertig"
},
"configInstructions": {
"title": "Konfigurationsanleitung",
"description": "Verwenden Sie Ihren API-Schlüssel zur Konfiguration dieser Tools.",
"claudeCode": {
"title": "Claude Code",
"description": "Fügen Sie dies zur MCP-Konfigurationsdatei von Claude Code hinzu:"
},
"cursor": {
"title": "Cursor",
"description": "Fügen Sie dies zu Ihren Cursor MCP-Einstellungen hinzu:"
},
"n8n": {
"title": "N8N",
"description": "Verwenden Sie diese Anmeldeinformationen in Ihrem N8N MCP-Knoten:"
}
}
}
}

View File

@@ -33,6 +33,8 @@
"reminders": "Reminders",
"labels": "Labels",
"editLabels": "Edit labels",
"newNoteTabs": "New Note",
"newNoteTabsHint": "Create note in this notebook",
"noLabelsInNotebook": "No labels in this notebook yet",
"archive": "Archive",
"trash": "Trash"
@@ -104,7 +106,7 @@
"large": "Large",
"shareWithCollaborators": "Share with collaborators",
"view": "View Note",
"edit": "Edit Note",
"edit": "Edit",
"readOnly": "Read Only",
"preview": "Preview",
"noContent": "No content",
@@ -128,6 +130,7 @@
"dragToReorder": "Drag to reorder",
"more": "More options",
"emptyState": "No notes yet. Create your first note!",
"emptyStateTabs": "No notes here yet. Use \"New note\" in the sidebar to add one (AI title suggestions appear in the composer).",
"inNotebook": "In notebook",
"moveFailed": "Failed to move note. Please try again.",
"clarifyFailed": "Failed to clarify text",
@@ -137,7 +140,15 @@
"markdown": "Markdown",
"unpinned": "Unpinned",
"redoShortcut": "Redo (Ctrl+Y)",
"undoShortcut": "Undo (Ctrl+Z)"
"undoShortcut": "Undo (Ctrl+Z)",
"viewCards": "Cards View",
"viewCardsTooltip": "Card grid with drag-and-drop reorder",
"viewTabs": "Tabs View",
"viewTabsTooltip": "Tabs on top, note below — drag tabs to reorder",
"viewModeGroup": "Notes display mode",
"reorderTabs": "Reorder tab",
"modified": "Modified",
"created": "Created"
},
"pagination": {
"previous": "←",
@@ -175,7 +186,8 @@
"loading": "Loading...",
"notebookRequired": "⚠️ Labels are only available in notebooks. Move this note to a notebook first.",
"count": "{count} labels",
"noLabels": "No labels"
"noLabels": "No labels",
"confirmDeleteShort": "Confirm?"
},
"search": {
"placeholder": "Search",
@@ -290,7 +302,21 @@
"notebookSummary": {
"regenerate": "Regenerate Summary",
"regenerating": "Regenerating summary..."
}
},
"clarifyDesc": "Make the text clearer and easier to understand",
"shortenDesc": "Summarize the text and get to the point",
"improve": "Improve writing",
"improveDesc": "Fix grammar and enhance style",
"toMarkdown": "Format as Markdown",
"toMarkdownDesc": "Add headings, bullet points and structure the text",
"translate": "Translate",
"translateDesc": "Change the text language",
"translateBack": "Back",
"translationApplied": "Translation applied",
"translationFailed": "Translation failed",
"undo": "Undo AI",
"undoAI": "Undo AI transformation",
"undoApplied": "Original text restored"
},
"titleSuggestions": {
"available": "Title suggestions",
@@ -397,7 +423,7 @@
"nav": {
"home": "Home",
"notes": "Notes",
"notebooks": "Notebooks",
"notebooks": "NOTEBOOKS",
"generalNotes": "General Notes",
"archive": "Archive",
"settings": "Settings",
@@ -461,7 +487,15 @@
"semanticIndexingDescription": "Generate vectors for all notes to enable intent-based search",
"profile": "Profile",
"searchNoResults": "No settings found",
"languageAuto": "Language set to Auto"
"languageAuto": "Language set to Auto",
"emailNotifications": "Email notifications",
"emailNotificationsDesc": "Receive important notifications by email",
"desktopNotifications": "Desktop notifications",
"desktopNotificationsDesc": "Receive notifications in your browser",
"anonymousAnalytics": "Anonymous analytics",
"anonymousAnalyticsDesc": "Share anonymous usage data to help improve the app",
"notificationsDesc": "Manage your notification preferences",
"privacyDesc": "Control your data and privacy"
},
"profile": {
"title": "Profile",
@@ -907,7 +941,11 @@
},
"appearance": {
"title": "Appearance",
"description": "Customize how the app looks"
"description": "Customize how the app looks",
"notesViewDescription": "Choose how notes are shown on home and in notebooks.",
"notesViewLabel": "Notes layout",
"notesViewTabs": "Tabs (OneNote-style)",
"notesViewMasonry": "Cards (grid)"
},
"generalSettings": {
"title": "General Settings",
@@ -1022,5 +1060,65 @@
"open": "Open",
"expand": "Expand",
"collapse": "Collapse"
},
"mcpSettings": {
"title": "MCP Settings",
"description": "Manage API keys and configure external tools",
"whatIsMcp": {
"title": "What is MCP?",
"description": "The Model Context Protocol (MCP) is an open protocol that enables AI models to securely interact with external tools and data sources. With MCP, you can connect tools like Claude Code, Cursor, or N8N to your Keep Notes instance to read, create, and organize your notes programmatically.",
"learnMore": "Learn more about MCP"
},
"serverStatus": {
"title": "Server Status",
"running": "Running",
"stopped": "Stopped",
"mode": "Mode",
"url": "URL"
},
"apiKeys": {
"title": "API Keys",
"description": "API keys allow external tools to access your notes via MCP. Keep your keys secret.",
"generate": "Generate a new key",
"empty": "No API keys yet. Generate one to get started.",
"active": "Active",
"revoked": "Revoked",
"revoke": "Revoke",
"delete": "Delete",
"createdAt": "Created",
"lastUsed": "Last used",
"never": "Never",
"confirmRevoke": "Are you sure you want to revoke this key? Tools using it will lose access.",
"confirmDelete": "Are you sure you want to permanently delete this key?"
},
"createDialog": {
"title": "Generate API Key",
"description": "Create a new API key to connect external tools to your notes.",
"nameLabel": "Key name",
"namePlaceholder": "e.g. Claude Code, Cursor, N8N",
"generating": "Generating...",
"generate": "Generate",
"successTitle": "API Key Generated",
"successDescription": "Copy your API key now. You won't be able to see it again.",
"copy": "Copy",
"copied": "Copied!",
"done": "Done"
},
"configInstructions": {
"title": "Configuration Instructions",
"description": "Use your API key to configure these tools.",
"claudeCode": {
"title": "Claude Code",
"description": "Add this to your Claude Code MCP configuration file:"
},
"cursor": {
"title": "Cursor",
"description": "Add this to your Cursor MCP settings:"
},
"n8n": {
"title": "N8N",
"description": "Use these credentials in your N8N MCP node:"
}
}
}
}

View File

@@ -992,5 +992,65 @@
"collapse": "Colapsar",
"expand": "Expandir",
"open": "Abrir"
},
"mcpSettings": {
"title": "Configuración MCP",
"description": "Gestiona tus claves API y configura herramientas externas",
"whatIsMcp": {
"title": "¿Qué es MCP?",
"description": "El Model Context Protocol (MCP) es un protocolo abierto que permite a los modelos de IA interactuar de forma segura con herramientas y fuentes de datos externas. Con MCP, puedes conectar herramientas como Claude Code, Cursor o N8N a tu instancia de Keep Notes para leer, crear y organizar tus notas mediante programación.",
"learnMore": "Más información sobre MCP"
},
"serverStatus": {
"title": "Estado del servidor",
"running": "En ejecución",
"stopped": "Detenido",
"mode": "Modo",
"url": "URL"
},
"apiKeys": {
"title": "Claves API",
"description": "Las claves API permiten a las herramientas externas acceder a tus notas mediante MCP. Mantén tus claves en secreto.",
"generate": "Generar nueva clave",
"empty": "Aún no hay claves API. Genera una para empezar.",
"active": "Activa",
"revoked": "Revocada",
"revoke": "Revocar",
"delete": "Eliminar",
"createdAt": "Creada",
"lastUsed": "Último uso",
"never": "Nunca",
"confirmRevoke": "¿Estás seguro de que quieres revocar esta clave? Las herramientas que la usen perderán el acceso.",
"confirmDelete": "¿Estás seguro de que quieres eliminar permanentemente esta clave?"
},
"createDialog": {
"title": "Generar clave API",
"description": "Crea una nueva clave API para conectar herramientas externas a tus notas.",
"nameLabel": "Nombre de la clave",
"namePlaceholder": "ej. Claude Code, Cursor, N8N",
"generating": "Generando...",
"generate": "Generar",
"successTitle": "Clave API generada",
"successDescription": "Copia tu clave API ahora. No podrás volver a verla.",
"copy": "Copiar",
"copied": "¡Copiada!",
"done": "Listo"
},
"configInstructions": {
"title": "Instrucciones de configuración",
"description": "Usa tu clave API para configurar estas herramientas.",
"claudeCode": {
"title": "Claude Code",
"description": "Añade esto a tu archivo de configuración MCP de Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Añade esto a tus ajustes MCP de Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Usa estas credenciales en tu nodo MCP de N8N:"
}
}
}
}

View File

@@ -229,7 +229,15 @@
"transformError": "خطا در تبدیل",
"transformMarkdown": "تبدیل به مارک‌داون",
"transformSuccess": "متن با موفقیت به مارک‌داون تبدیل شد!",
"transforming": "در حال تبدیل..."
"transforming": "در حال تبدیل...",
"translate": "ترجمه",
"translateDesc": "تغییر زبان متن",
"translateBack": "بازگشت",
"translationApplied": "ترجمه اعمال شد",
"translationFailed": "ترجمه ناموفق بود",
"undo": "لغو هوش مصنوعی",
"undoAI": "لغو تبدیل هوش مصنوعی",
"undoApplied": "متن اصلی بازگردانده شد"
},
"aiSettings": {
"description": "ویژگی‌ها و ترجیحات هوش مصنوعی خود را پیکربندی کنید",
@@ -568,7 +576,7 @@
"delete": "حذف",
"deleteTooltip": "حذف برچسب",
"editLabels": "ویرایش برچسب‌ها",
"editLabelsDescription": "ایجاد، ویرایش رنگ‌ها یا حذف برچسب‌ها.",
"editLabelsDescription": "برچسب‌های خود را مدیریت کنید",
"filter": "فیلتر بر اساس برچسب",
"filterByLabel": "فیلتر بر اساس برچسب",
"labelColor": "رنگ برچسب",
@@ -579,15 +587,16 @@
"manageLabelsDescription": "Add or remove labels for this note. Click on a label to change its color.",
"manageTooltip": "مدیریت برچسب‌ها",
"namePlaceholder": "نام برچسب",
"newLabelPlaceholder": "ایجاد برچسب جدید",
"newLabelPlaceholder": "برچسب جدید...",
"noLabels": "بدون برچسب",
"noLabelsFound": "برچسبی یافت نشد.",
"noLabelsFound": "برچسبی یافت نشد",
"notebookRequired": "⚠️ برچسب‌ها فقط در دفترچه‌ها در دسترس هستند. این یادداشت را ابتدا به یک دفترچه منتقل کنید.",
"selectedLabels": "Selected Labels",
"showLess": "نمایش کمتر",
"showMore": "نمایش بیشتر",
"tagAdded": "برچسب \"{tag}\" اضافه شد",
"title": "برچسب‌ها"
"title": "برچسب‌ها",
"confirmDeleteShort": "تایید؟"
},
"memoryEcho": {
"clickToView": "برای مشاهده یادداشت کلیک کنید",
@@ -684,7 +693,7 @@
"logout": "خروج",
"manageAISettings": "Manage AI Settings",
"myLibrary": "کتابخانه من",
"notebooks": "Notebooks",
"notebooks": "دفترچه‌ها",
"notes": "یادداشت‌ها",
"proPlan": "پلن پرو",
"profile": "پروفایل",
@@ -765,7 +774,7 @@
"delete": "حذف",
"dragToReorder": "بکشید تا مرتب کنید",
"duplicate": "تکثیر",
"edit": "Edit Note",
"edit": "ویرایش",
"emptyState": "یادداشتی نیست",
"fileTooLarge": "فایل خیلی بزرگ است: {fileName}. حداکثر اندازه {maxSize}.",
"improveFailed": "بهبود شکست خورد",
@@ -801,7 +810,7 @@
"pinned": "سنجاق شده",
"pinnedNotes": "یادداشت‌های سنجاق شده",
"placeholder": "یادداشت بگیرید...",
"preview": "Preview",
"preview": "پیش‌نمایش",
"readOnly": "Read Only",
"recent": "اخیر",
"redo": "انجام مجدد",
@@ -837,7 +846,11 @@
"unpinned": "سنجاق نشده",
"untitled": "بدون عنوان",
"uploadFailed": "آپلود {fileName} شکست خورد",
"view": "View Note"
"view": "View Note",
"modified": "ویرایش شده",
"created": "ایجاد شده",
"viewTabs": "نمایش زبانه‌ای",
"viewCards": "نمایش کارتی"
},
"pagination": {
"next": "→",
@@ -962,15 +975,26 @@
"themeLight": "روشن",
"themeSystem": "سیستم",
"title": "تنظیمات",
"version": "نسخه"
"version": "نسخه",
"emailNotifications": "اعلان‌های ایمیل",
"emailNotificationsDesc": "دریافت اعلان‌های مهم از طریق ایمیل",
"desktopNotifications": "اعلان‌های مرورگر",
"desktopNotificationsDesc": "دریافت اعلان‌ها در مرورگر",
"anonymousAnalytics": "تحلیل‌های ناشناس",
"anonymousAnalyticsDesc": "اشتراک داده‌های استفاده ناشناس برای بهبود برنامه",
"notificationsDesc": "مدیریت تنظیمات اعلان",
"privacyDesc": "کنترل داده‌ها و حریم خصوصی شما"
},
"sidebar": {
"archive": "Archive",
"editLabels": "Edit labels",
"archive": "بایگانی",
"editLabels": "ویرایش برچسب‌ها",
"labels": "Labels",
"notes": "Notes",
"reminders": "Reminders",
"trash": "Trash"
"notes": "یادداشت‌ها",
"reminders": "یادآورها",
"trash": "زباله‌دان",
"newNoteTabs": "یادداشت جدید",
"newNoteTabsHint": "ایجاد یادداشت جدید در این دفترچه",
"edit": "ویرایش یادداشت"
},
"support": {
"aiApiCosts": "هزینه‌های AI API:",
@@ -1050,5 +1074,65 @@
"collapse": "جمع کردن",
"expand": "بسط دادن",
"open": "باز کردن"
},
"mcpSettings": {
"title": "تنظیمات MCP",
"description": "مدیریت کلیدهای API و پیکربندی ابزارهای خارجی",
"whatIsMcp": {
"title": "MCP چیست؟",
"description": "پروتکل زمینه مدل (MCP) یک پروتکل باز است که به مدل‌های هوش مصنوعی امکان تعامل امن با ابزارها و منابع داده خارجی را می‌دهد. با MCP می‌توانید ابزارهایی مانند Claude Code، Cursor یا N8N را به نمونه Keep Notes خود متصل کنید تا یادداشت‌های خود را به صورت برنامه‌نویسی بخوانید، ایجاد کنید و سازماندهی کنید.",
"learnMore": "بیشتر درباره MCP بدانید"
},
"serverStatus": {
"title": "وضعیت سرور",
"running": "در حال اجرا",
"stopped": "متوقف",
"mode": "حالت",
"url": "URL"
},
"apiKeys": {
"title": "کلیدهای API",
"description": "کلیدهای API به ابزارهای خارجی اجازه می‌دهند از طریق MCP به یادداشت‌های شما دسترسی پیدا کنند. کلیدهای خود را محرمانه نگه دارید.",
"generate": "ایجاد کلید جدید",
"empty": "هنوز کلید API وجود ندارد. برای شروع یکی ایجاد کنید.",
"active": "فعال",
"revoked": "لغو شده",
"revoke": "لغو",
"delete": "حذف",
"createdAt": "تاریخ ایجاد",
"lastUsed": "آخرین استفاده",
"never": "هرگز",
"confirmRevoke": "آیا مطمئن هستید که می‌خواهید این کلید را لغو کنید؟ ابزارهایی که از آن استفاده می‌کنند دسترسی خود را از دست می‌دهند.",
"confirmDelete": "آیا مطمئن هستید که می‌خواهید این کلید را برای همیشه حذف کنید؟"
},
"createDialog": {
"title": "ایجاد کلید API",
"description": "یک کلید API جدید برای اتصال ابزارهای خارجی به یادداشت‌های خود ایجاد کنید.",
"nameLabel": "نام کلید",
"namePlaceholder": "مثال: Claude Code، Cursor، N8N",
"generating": "در حال ایجاد...",
"generate": "ایجاد",
"successTitle": "کلید API ایجاد شد",
"successDescription": "کلید API خود را همین حالا کپی کنید. دیگر نمی‌توانید آن را ببینید.",
"copy": "کپی",
"copied": "کپی شد!",
"done": "انجام شد"
},
"configInstructions": {
"title": "دستورالعمل پیکربندی",
"description": "از کلید API خود برای پیکربندی این ابزارها استفاده کنید.",
"claudeCode": {
"title": "Claude Code",
"description": "این را به فایل پیکربندی MCP Claude Code خود اضافه کنید:"
},
"cursor": {
"title": "Cursor",
"description": "این را به تنظیمات MCP Cursor خود اضافه کنید:"
},
"n8n": {
"title": "N8N",
"description": "از این اعتبارنامه‌ها در گره N8N MCP خود استفاده کنید:"
}
}
}
}
}

View File

@@ -254,7 +254,21 @@
"transformError": "Erreur lors de la transformation",
"transformMarkdown": "Transformer en Markdown",
"transformSuccess": "Texte transformé en Markdown avec succès !",
"transforming": "Transformation..."
"transforming": "Transformation...",
"clarifyDesc": "Rendre le propos plus clair et compréhensible",
"shortenDesc": "Résumer le texte et aller à l'essentiel",
"improve": "Améliorer la rédaction",
"improveDesc": "Corriger les fautes et le style",
"toMarkdown": "Formater en Markdown",
"toMarkdownDesc": "Ajouter des titres, des puces et structurer le texte",
"translate": "Traduire",
"translateDesc": "Changer la langue du texte",
"translateBack": "Retour",
"translationApplied": "Traduction appliquée",
"translationFailed": "Traduction échouée",
"undo": "Annuler IA",
"undoAI": "Annuler la transformation IA",
"undoApplied": "Texte original restauré"
},
"aiSettings": {
"description": "Configurez vos fonctionnalités IA et préférences",
@@ -280,6 +294,10 @@
},
"appearance": {
"description": "Personnaliser l'apparence de l'application",
"notesViewDescription": "Choisissez comment afficher les notes sur l'accueil et dans les carnets.",
"notesViewLabel": "Affichage des notes",
"notesViewTabs": "Onglets (type OneNote)",
"notesViewMasonry": "Cartes (grille)",
"title": "Apparence"
},
"auth": {
@@ -557,7 +575,8 @@
"showLess": "Voir moins",
"showMore": "Voir plus",
"tagAdded": "Étiquette \"{tag}\" ajoutée",
"title": "Étiquettes"
"title": "Étiquettes",
"confirmDeleteShort": "Confirmer ?"
},
"memoryEcho": {
"clickToView": "Cliquer pour voir la note →",
@@ -659,7 +678,7 @@
"logout": "Déconnexion",
"manageAISettings": "Gérer les paramètres IA",
"myLibrary": "Ma bibliothèque",
"notebooks": "Cahiers",
"notebooks": "CARNETS",
"notes": "Notes",
"proPlan": "Pro Plan",
"profile": "Profil",
@@ -749,8 +768,9 @@
"delete": "Supprimer",
"dragToReorder": "Glisser pour réorganiser",
"duplicate": "Dupliquer",
"edit": "Modifier la note",
"edit": "Éditer",
"emptyState": "Aucune note encore. Créez votre première note !",
"emptyStateTabs": "Aucune note dans cette vue. Utilisez « Nouvelle note » dans la barre latérale pour en ajouter une (titres suggérés par lIA disponibles dans le compositeur).",
"fileTooLarge": "Fichier trop volumineux : {fileName}. Taille maximale : {maxSize}.",
"improveFailed": "Échec de l'amélioration du texte",
"inNotebook": "Dans le carnet",
@@ -821,7 +841,15 @@
"unpinned": "Désépinglées",
"untitled": "Sans titre",
"uploadFailed": "Échec du téléchargement",
"view": "Voir la note"
"view": "Voir la note",
"viewCards": "Vue par cartes",
"viewCardsTooltip": "Grille de cartes et réorganisation par glisser-déposer",
"viewTabs": "Vue par onglets",
"viewTabsTooltip": "Onglets en haut, contenu dessous — glisser les onglets pour réordonner",
"viewModeGroup": "Mode d'affichage des notes",
"reorderTabs": "Réordonner l'onglet",
"modified": "Modifiée",
"created": "Créée"
},
"pagination": {
"next": "→",
@@ -952,7 +980,15 @@
"themeLight": "Clair",
"themeSystem": "Système",
"title": "Paramètres",
"version": "Version"
"version": "Version",
"emailNotifications": "Notifications par email",
"emailNotificationsDesc": "Recevoir des notifications importantes par email",
"desktopNotifications": "Notifications bureau",
"desktopNotificationsDesc": "Recevoir des notifications dans votre navigateur",
"anonymousAnalytics": "Analyses anonymes",
"anonymousAnalyticsDesc": "Partager des données d'utilisation anonymes pour améliorer l'application",
"notificationsDesc": "Gérez vos préférences de notifications",
"privacyDesc": "Contrôlez vos données et votre confidentialité"
},
"reminders": {
"title": "Rappels",
@@ -970,6 +1006,8 @@
"archive": "Archives",
"editLabels": "Modifier les étiquettes",
"labels": "Étiquettes",
"newNoteTabs": "Nouvelle note",
"newNoteTabsHint": "Créer une note dans ce carnet",
"noLabelsInNotebook": "Aucune étiquette dans ce carnet",
"notes": "Notes",
"reminders": "Rappels",
@@ -1053,5 +1091,65 @@
"collapse": "Réduire",
"expand": "Développer",
"open": "Ouvrir"
},
"mcpSettings": {
"title": "Paramètres MCP",
"description": "Gérez vos clés API et configurez les outils externes",
"whatIsMcp": {
"title": "Qu'est-ce que MCP ?",
"description": "Le Model Context Protocol (MCP) est un protocole ouvert qui permet aux modèles IA d'interagir de manière sécurisée avec des outils et sources de données externes. Avec MCP, vous pouvez connecter des outils comme Claude Code, Cursor ou N8N à votre instance Keep Notes pour lire, créer et organiser vos notes par programmation.",
"learnMore": "En savoir plus sur MCP"
},
"serverStatus": {
"title": "État du serveur",
"running": "En cours",
"stopped": "Arrêté",
"mode": "Mode",
"url": "URL"
},
"apiKeys": {
"title": "Clés API",
"description": "Les clés API permettent aux outils externes d'accéder à vos notes via MCP. Gardez vos clés secrètes.",
"generate": "Générer une nouvelle clé",
"empty": "Aucune clé API. Générez-en une pour commencer.",
"active": "Active",
"revoked": "Révoquée",
"revoke": "Révoquer",
"delete": "Supprimer",
"createdAt": "Créée le",
"lastUsed": "Dernière utilisation",
"never": "Jamais",
"confirmRevoke": "Êtes-vous sûr de vouloir révoquer cette clé ? Les outils qui l'utilisent perdront leur accès.",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer définitivement cette clé ?"
},
"createDialog": {
"title": "Générer une clé API",
"description": "Créez une nouvelle clé API pour connecter des outils externes à vos notes.",
"nameLabel": "Nom de la clé",
"namePlaceholder": "ex. Claude Code, Cursor, N8N",
"generating": "Génération...",
"generate": "Générer",
"successTitle": "Clé API générée",
"successDescription": "Copiez votre clé API maintenant. Vous ne pourrez plus la voir ensuite.",
"copy": "Copier",
"copied": "Copiée !",
"done": "Terminé"
},
"configInstructions": {
"title": "Instructions de configuration",
"description": "Utilisez votre clé API pour configurer ces outils.",
"claudeCode": {
"title": "Claude Code",
"description": "Ajoutez ceci à votre fichier de configuration MCP de Claude Code :"
},
"cursor": {
"title": "Cursor",
"description": "Ajoutez ceci à vos paramètres MCP de Cursor :"
},
"n8n": {
"title": "N8N",
"description": "Utilisez ces identifiants dans votre nœud MCP N8N :"
}
}
}
}
}

View File

@@ -997,5 +997,65 @@
"collapse": "संकुचित करें",
"expand": "विस्तार करें",
"open": "खोलें"
},
"mcpSettings": {
"title": "MCP सेटिंग्स",
"description": "API कुंजियाँ प्रबंधित करें और बाहरी टूल कॉन्फ़िगर करें",
"whatIsMcp": {
"title": "MCP क्या है?",
"description": "मॉडल कॉन्टेक्स्ट प्रोटोकॉल (MCP) एक खुला प्रोटोकॉल है जो AI मॉडल को बाहरी टूल और डेटा स्रोतों के साथ सुरक्षित रूप से इंटरैक्ट करने में सक्षम बनाता है। MCP के साथ, आप Claude Code, Cursor या N8N जैसे टूल को अपने Keep Notes इंस्टेंस से कनेक्ट करके प्रोग्रामेटिक रूप से अपने नोट्स को पढ़ सकते हैं, बना सकते हैं और व्यवस्थित कर सकते हैं।",
"learnMore": "MCP के बारे में और जानें"
},
"serverStatus": {
"title": "सर्वर स्थिति",
"running": "चल रहा है",
"stopped": "रुका हुआ",
"mode": "मोड",
"url": "URL"
},
"apiKeys": {
"title": "API कुंजियाँ",
"description": "API कुंजियाँ बाहरी टूल को MCP के माध्यम से आपके नोट्स तक पहुँचने की अनुमति देती हैं। अपनी कुंजियों को गोपनीय रखें।",
"generate": "नई कुंजी जनरेट करें",
"empty": "अभी तक कोई API कुंजी नहीं। शुरू करने के लिए एक जनरेट करें।",
"active": "सक्रिय",
"revoked": "निरस्त",
"revoke": "निरस्त करें",
"delete": "हटाएँ",
"createdAt": "बनाई गई",
"lastUsed": "अंतिम उपयोग",
"never": "कभी नहीं",
"confirmRevoke": "क्या आप वाकई इस कुंजी को निरस्त करना चाहते हैं? इसका उपयोग करने वाले टूल की पहुँच हट जाएगी।",
"confirmDelete": "क्या आप वाकई इस कुंजी को स्थायी रूप से हटाना चाहते हैं?"
},
"createDialog": {
"title": "API कुंजी जनरेट करें",
"description": "बाहरी टूल को अपने नोट्स से कनेक्ट करने के लिए एक नई API कुंजी बनाएँ।",
"nameLabel": "कुंजी का नाम",
"namePlaceholder": "उदा. Claude Code, Cursor, N8N",
"generating": "जनरेट हो रहा है...",
"generate": "जनरेट करें",
"successTitle": "API कुंजी जनरेट हो गई",
"successDescription": "अपनी API कुंजी अभी कॉपी करें। आप इसे दोबारा नहीं देख पाएँगे।",
"copy": "कॉपी करें",
"copied": "कॉपी हो गई!",
"done": "हो गया"
},
"configInstructions": {
"title": "कॉन्फ़िगरेशन निर्देश",
"description": "इन टूल को कॉन्फ़िगर करने के लिए अपनी API कुंजी का उपयोग करें।",
"claudeCode": {
"title": "Claude Code",
"description": "इसे अपनी Claude Code MCP कॉन्फ़िगरेशन फ़ाइल में जोड़ें:"
},
"cursor": {
"title": "Cursor",
"description": "इसे अपनी Cursor MCP सेटिंग्स में जोड़ें:"
},
"n8n": {
"title": "N8N",
"description": "अपने N8N MCP नोड में इन क्रेडेंशियल का उपयोग करें:"
}
}
}
}

View File

@@ -1041,5 +1041,65 @@
"collapse": "Comprimi",
"expand": "Espandi",
"open": "Apri"
},
"mcpSettings": {
"title": "Impostazioni MCP",
"description": "Gestisci le chiavi API e configura gli strumenti esterni",
"whatIsMcp": {
"title": "Cos'è MCP?",
"description": "Il Model Context Protocol (MCP) è un protocollo aperto che consente ai modelli di IA di interagire in modo sicuro con strumenti e fonti di dati esterni. Con MCP puoi collegare strumenti come Claude Code, Cursor o N8N alla tua istanza Keep Notes per leggere, creare e organizzare le tue note a livello di programmazione.",
"learnMore": "Scopri di più su MCP"
},
"serverStatus": {
"title": "Stato del server",
"running": "In esecuzione",
"stopped": "Arrestato",
"mode": "Modalità",
"url": "URL"
},
"apiKeys": {
"title": "Chiavi API",
"description": "Le chiavi API consentono agli strumenti esterni di accedere alle tue note tramite MCP. Mantieni le tue chiavi segrete.",
"generate": "Genera una nuova chiave",
"empty": "Nessuna chiave API. Generane una per iniziare.",
"active": "Attiva",
"revoked": "Revocata",
"revoke": "Revoca",
"delete": "Elimina",
"createdAt": "Creata",
"lastUsed": "Ultimo utilizzo",
"never": "Mai",
"confirmRevoke": "Sei sicuro di voler revocare questa chiave? Gli strumenti che la utilizzano perderanno l'accesso.",
"confirmDelete": "Sei sicuro di voler eliminare definitivamente questa chiave?"
},
"createDialog": {
"title": "Genera chiave API",
"description": "Crea una nuova chiave API per collegare strumenti esterni alle tue note.",
"nameLabel": "Nome della chiave",
"namePlaceholder": "es. Claude Code, Cursor, N8N",
"generating": "Generazione...",
"generate": "Genera",
"successTitle": "Chiave API generata",
"successDescription": "Copia la tua chiave API ora. Non potrai vederla di nuovo.",
"copy": "Copia",
"copied": "Copiata!",
"done": "Fatto"
},
"configInstructions": {
"title": "Istruzioni di configurazione",
"description": "Usa la tua chiave API per configurare questi strumenti.",
"claudeCode": {
"title": "Claude Code",
"description": "Aggiungi questo al tuo file di configurazione MCP di Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Aggiungi questo alle tue impostazioni MCP di Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Usa queste credenziali nel tuo nodo MCP N8N:"
}
}
}
}

View File

@@ -997,5 +997,65 @@
"collapse": "折りたたむ",
"expand": "展開",
"open": "開く"
},
"mcpSettings": {
"title": "MCP設定",
"description": "APIキーの管理と外部ツールの設定",
"whatIsMcp": {
"title": "MCPとは",
"description": "Model Context ProtocolMCPは、AIモデルが外部ツールやデータソースと安全にやり取りできるようにするオープンプロトコルです。MCPを使用すると、Claude Code、Cursor、N8NなどのツールをKeep Notesインスタンスに接続し、プログラムでートの読み取り、作成、整理を行うことができます。",
"learnMore": "MCPについて詳しく知る"
},
"serverStatus": {
"title": "サーバーステータス",
"running": "実行中",
"stopped": "停止",
"mode": "モード",
"url": "URL"
},
"apiKeys": {
"title": "APIキー",
"description": "APIキーにより、外部ツールがMCP経由でートにアクセスできます。キーは秘密にしてください。",
"generate": "新しいキーを生成",
"empty": "APIキーはまだありません。生成して始めましょう。",
"active": "有効",
"revoked": "無効化",
"revoke": "無効化",
"delete": "削除",
"createdAt": "作成日",
"lastUsed": "最終使用",
"never": "未使用",
"confirmRevoke": "このキーを無効化してよろしいですか?使用中のツールがアクセスを失います。",
"confirmDelete": "このキーを永久に削除してよろしいですか?"
},
"createDialog": {
"title": "APIキーを生成",
"description": "外部ツールをートに接続するための新しいAPIキーを作成します。",
"nameLabel": "キー名",
"namePlaceholder": "例Claude Code、Cursor、N8N",
"generating": "生成中...",
"generate": "生成",
"successTitle": "APIキーが生成されました",
"successDescription": "今すぐAPIキーをコピーしてください。後で再度表示することはできません。",
"copy": "コピー",
"copied": "コピーしました!",
"done": "完了"
},
"configInstructions": {
"title": "設定手順",
"description": "APIキーを使用してこれらのツールを設定してください。",
"claudeCode": {
"title": "Claude Code",
"description": "Claude CodeのMCP設定ファイルに以下を追加してください"
},
"cursor": {
"title": "Cursor",
"description": "CursorのMCP設定に以下を追加してください"
},
"n8n": {
"title": "N8N",
"description": "N8N MCPードで以下の認証情報を使用してください"
}
}
}
}

View File

@@ -997,5 +997,65 @@
"collapse": "접기",
"expand": "펼치기",
"open": "열기"
},
"mcpSettings": {
"title": "MCP 설정",
"description": "API 키 관리 및 외부 도구 구성",
"whatIsMcp": {
"title": "MCP란 무엇인가요?",
"description": "Model Context Protocol(MCP)은 AI 모델이 외부 도구 및 데이터 소스와 안전하게 상호 작용할 수 있게 하는 오픈 프로토콜입니다. MCP를 사용하면 Claude Code, Cursor, N8N 등의 도구를 Keep Notes 인스턴스에 연결하여 프로그래밍 방식으로 노트를 읽고, 만들고, 정리할 수 있습니다.",
"learnMore": "MCP에 대해 자세히 알아보기"
},
"serverStatus": {
"title": "서버 상태",
"running": "실행 중",
"stopped": "중지됨",
"mode": "모드",
"url": "URL"
},
"apiKeys": {
"title": "API 키",
"description": "API 키를 통해 외부 도구가 MCP를 통해 노트에 접근할 수 있습니다. 키를 비밀로 유지하세요.",
"generate": "새 키 생성",
"empty": "API 키가 없습니다. 하나를 생성하여 시작하세요.",
"active": "활성",
"revoked": "취소됨",
"revoke": "취소",
"delete": "삭제",
"createdAt": "생성일",
"lastUsed": "마지막 사용",
"never": "없음",
"confirmRevoke": "이 키를 취소하시겠습니까? 이 키를 사용하는 도구의 접근이 차단됩니다.",
"confirmDelete": "이 키를 영구적으로 삭제하시겠습니까?"
},
"createDialog": {
"title": "API 키 생성",
"description": "외부 도구를 노트에 연결할 새 API 키를 만듭니다.",
"nameLabel": "키 이름",
"namePlaceholder": "예: Claude Code, Cursor, N8N",
"generating": "생성 중...",
"generate": "생성",
"successTitle": "API 키가 생성되었습니다",
"successDescription": "지금 API 키를 복사하세요. 나중에 다시 볼 수 없습니다.",
"copy": "복사",
"copied": "복사됨!",
"done": "완료"
},
"configInstructions": {
"title": "구성 안내",
"description": "API 키를 사용하여 이 도구들을 구성하세요.",
"claudeCode": {
"title": "Claude Code",
"description": "Claude Code MCP 구성 파일에 다음을 추가하세요:"
},
"cursor": {
"title": "Cursor",
"description": "Cursor MCP 설정에 다음을 추가하세요:"
},
"n8n": {
"title": "N8N",
"description": "N8N MCP 노드에서 다음 자격 증명을 사용하세요:"
}
}
}
}

View File

@@ -1041,5 +1041,65 @@
"collapse": "Inklappen",
"expand": "Uitvouwen",
"open": "Openen"
},
"mcpSettings": {
"title": "MCP-instellingen",
"description": "Beheer uw API-sleutels en configureer externe tools",
"whatIsMcp": {
"title": "Wat is MCP?",
"description": "Het Model Context Protocol (MCP) is een open protocol waarmee AI-modellen veilig kunnen communiceren met externe tools en gegevensbronnen. Met MCP kunt u tools zoals Claude Code, Cursor of N8N koppelen aan uw Keep Notes-instantie om uw notities programmatisch te lezen, maken en organiseren.",
"learnMore": "Meer informatie over MCP"
},
"serverStatus": {
"title": "Serverstatus",
"running": "Actief",
"stopped": "Gestopt",
"mode": "Modus",
"url": "URL"
},
"apiKeys": {
"title": "API-sleutels",
"description": "API-sleutels geven externe tools toegang tot uw notities via MCP. Houd uw sleutels geheim.",
"generate": "Nieuwe sleutel genereren",
"empty": "Nog geen API-sleutels. Genereer er een om te beginnen.",
"active": "Actief",
"revoked": "Ingetrokken",
"revoke": "Intrekken",
"delete": "Verwijderen",
"createdAt": "Aangemaakt",
"lastUsed": "Laatst gebruikt",
"never": "Nooit",
"confirmRevoke": "Weet u zeker dat u deze sleutel wilt intrekken? Tools die deze gebruiken, verliezen toegang.",
"confirmDelete": "Weet u zeker dat u deze sleutel permanent wilt verwijderen?"
},
"createDialog": {
"title": "API-sleutel genereren",
"description": "Maak een nieuwe API-sleutel aan om externe tools met uw notities te verbinden.",
"nameLabel": "Sleutelnaam",
"namePlaceholder": "bijv. Claude Code, Cursor, N8N",
"generating": "Bezig met genereren...",
"generate": "Genereren",
"successTitle": "API-sleutel gegenereerd",
"successDescription": "Kopieer uw API-sleutel nu. U kunt deze later niet meer bekijken.",
"copy": "Kopiëren",
"copied": "Gekopieerd!",
"done": "Klaar"
},
"configInstructions": {
"title": "Configuratie-instructies",
"description": "Gebruik uw API-sleutel om deze tools te configureren.",
"claudeCode": {
"title": "Claude Code",
"description": "Voeg dit toe aan uw Claude Code MCP-configuratiebestand:"
},
"cursor": {
"title": "Cursor",
"description": "Voeg dit toe aan uw Cursor MCP-instellingen:"
},
"n8n": {
"title": "N8N",
"description": "Gebruik deze referenties in uw N8N MCP-node:"
}
}
}
}

View File

@@ -1063,5 +1063,65 @@
"collapse": "Zwiń",
"expand": "Rozwiń",
"open": "Otwórz"
},
"mcpSettings": {
"title": "Ustawienia MCP",
"description": "Zarządzaj kluczami API i konfiguruj narzędzia zewnętrzne",
"whatIsMcp": {
"title": "Czym jest MCP?",
"description": "Model Context Protocol (MCP) to otwarty protokół umożliwiający modelom AI bezpieczną interakcję z zewnętrznymi narzędziami i źródłami danych. Dzięki MCP możesz połączyć narzędzia takie jak Claude Code, Cursor czy N8N ze swoją instancją Keep Notes, aby programowo czytać, tworzyć i organizować notatki.",
"learnMore": "Dowiedz się więcej o MCP"
},
"serverStatus": {
"title": "Status serwera",
"running": "Uruchomiony",
"stopped": "Zatrzymany",
"mode": "Tryb",
"url": "URL"
},
"apiKeys": {
"title": "Klucze API",
"description": "Klucze API pozwalają narzędziom zewnętrznym uzyskiwać dostęp do Twoich notatek przez MCP. Zachowaj klucze w tajemnicy.",
"generate": "Wygeneruj nowy klucz",
"empty": "Brak kluczy API. Wygeneruj jeden, aby zacząć.",
"active": "Aktywny",
"revoked": "Unieważniony",
"revoke": "Unieważnij",
"delete": "Usuń",
"createdAt": "Utworzono",
"lastUsed": "Ostatnio użyty",
"never": "Nigdy",
"confirmRevoke": "Czy na pewno chcesz unieważnić ten klucz? Narzędzia korzystające z niego stracą dostęp.",
"confirmDelete": "Czy na pewno chcesz trwale usunąć ten klucz?"
},
"createDialog": {
"title": "Wygeneruj klucz API",
"description": "Utwórz nowy klucz API, aby połączyć narzędzia zewnętrzne ze swoimi notatkami.",
"nameLabel": "Nazwa klucza",
"namePlaceholder": "np. Claude Code, Cursor, N8N",
"generating": "Generowanie...",
"generate": "Wygeneruj",
"successTitle": "Klucz API wygenerowany",
"successDescription": "Skopiuj swój klucz API teraz. Nie będziesz mógł go ponownie zobaczyć.",
"copy": "Kopiuj",
"copied": "Skopiowano!",
"done": "Gotowe"
},
"configInstructions": {
"title": "Instrukcje konfiguracji",
"description": "Użyj swojego klucza API do konfiguracji tych narzędzi.",
"claudeCode": {
"title": "Claude Code",
"description": "Dodaj to do pliku konfiguracyjnego MCP Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Dodaj to do ustawień MCP Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Użyj tych danych logowania w węźle N8N MCP:"
}
}
}
}

View File

@@ -991,5 +991,65 @@
"collapse": "Recolher",
"expand": "Expandir",
"open": "Abrir"
},
"mcpSettings": {
"title": "Configurações MCP",
"description": "Gerencie suas chaves API e configure ferramentas externas",
"whatIsMcp": {
"title": "O que é MCP?",
"description": "O Model Context Protocol (MCP) é um protocolo aberto que permite que modelos de IA interajam de forma segura com ferramentas e fontes de dados externas. Com o MCP, você pode conectar ferramentas como Claude Code, Cursor ou N8N à sua instância do Keep Notes para ler, criar e organizar suas notas programaticamente.",
"learnMore": "Saiba mais sobre o MCP"
},
"serverStatus": {
"title": "Status do servidor",
"running": "Em execução",
"stopped": "Parado",
"mode": "Modo",
"url": "URL"
},
"apiKeys": {
"title": "Chaves API",
"description": "As chaves API permitem que ferramentas externas acessem suas notas via MCP. Mantenha suas chaves em segredo.",
"generate": "Gerar nova chave",
"empty": "Nenhuma chave API ainda. Gere uma para começar.",
"active": "Ativa",
"revoked": "Revogada",
"revoke": "Revogar",
"delete": "Excluir",
"createdAt": "Criada",
"lastUsed": "Último uso",
"never": "Nunca",
"confirmRevoke": "Tem certeza de que deseja revogar esta chave? As ferramentas que a usam perderão o acesso.",
"confirmDelete": "Tem certeza de que deseja excluir permanentemente esta chave?"
},
"createDialog": {
"title": "Gerar chave API",
"description": "Crie uma nova chave API para conectar ferramentas externas às suas notas.",
"nameLabel": "Nome da chave",
"namePlaceholder": "ex. Claude Code, Cursor, N8N",
"generating": "Gerando...",
"generate": "Gerar",
"successTitle": "Chave API gerada",
"successDescription": "Copie sua chave API agora. Você não poderá vê-la novamente.",
"copy": "Copiar",
"copied": "Copiada!",
"done": "Concluído"
},
"configInstructions": {
"title": "Instruções de configuração",
"description": "Use sua chave API para configurar estas ferramentas.",
"claudeCode": {
"title": "Claude Code",
"description": "Adicione isto ao seu arquivo de configuração MCP do Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Adicione isto às suas configurações MCP do Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Use estas credenciais no seu nó MCP do N8N:"
}
}
}
}

View File

@@ -991,5 +991,65 @@
"collapse": "Свернуть",
"expand": "Развернуть",
"open": "Открыть"
},
"mcpSettings": {
"title": "Настройки MCP",
"description": "Управление ключами API и настройка внешних инструментов",
"whatIsMcp": {
"title": "Что такое MCP?",
"description": "Model Context Protocol (MCP) — это открытый протокол, позволяющий моделям ИИ безопасно взаимодействовать с внешними инструментами и источниками данных. С помощью MCP вы можете подключить такие инструменты, как Claude Code, Cursor или N8N, к вашему экземпляру Keep Notes для программного чтения, создания и организации заметок.",
"learnMore": "Подробнее о MCP"
},
"serverStatus": {
"title": "Состояние сервера",
"running": "Запущен",
"stopped": "Остановлен",
"mode": "Режим",
"url": "URL"
},
"apiKeys": {
"title": "Ключи API",
"description": "Ключи API позволяют внешним инструментам получать доступ к вашим заметкам через MCP. Храните ключи в секрете.",
"generate": "Создать новый ключ",
"empty": "Нет ключей API. Создайте один, чтобы начать.",
"active": "Активен",
"revoked": "Отозван",
"revoke": "Отозвать",
"delete": "Удалить",
"createdAt": "Создан",
"lastUsed": "Последнее использование",
"never": "Никогда",
"confirmRevoke": "Вы уверены, что хотите отозвать этот ключ? Инструменты, использующие его, потеряют доступ.",
"confirmDelete": "Вы уверены, что хотите навсегда удалить этот ключ?"
},
"createDialog": {
"title": "Создать ключ API",
"description": "Создайте новый ключ API для подключения внешних инструментов к вашим заметкам.",
"nameLabel": "Имя ключа",
"namePlaceholder": "напр. Claude Code, Cursor, N8N",
"generating": "Генерация...",
"generate": "Создать",
"successTitle": "Ключ API создан",
"successDescription": "Скопируйте ключ API сейчас. Вы не сможете увидеть его снова.",
"copy": "Копировать",
"copied": "Скопировано!",
"done": "Готово"
},
"configInstructions": {
"title": "Инструкции по настройке",
"description": "Используйте свой ключ API для настройки этих инструментов.",
"claudeCode": {
"title": "Claude Code",
"description": "Добавьте это в файл конфигурации MCP Claude Code:"
},
"cursor": {
"title": "Cursor",
"description": "Добавьте это в настройки MCP Cursor:"
},
"n8n": {
"title": "N8N",
"description": "Используйте эти учётные данные в узле N8N MCP:"
}
}
}
}

View File

@@ -997,5 +997,65 @@
"collapse": "收起",
"expand": "展开",
"open": "打开"
},
"mcpSettings": {
"title": "MCP 设置",
"description": "管理 API 密钥并配置外部工具",
"whatIsMcp": {
"title": "什么是 MCP",
"description": "模型上下文协议MCP是一个开放协议使 AI 模型能够与外部工具和数据源安全交互。通过 MCP您可以将 Claude Code、Cursor 或 N8N 等工具连接到您的 Keep Notes 实例,以编程方式读取、创建和整理笔记。",
"learnMore": "了解更多关于 MCP"
},
"serverStatus": {
"title": "服务器状态",
"running": "运行中",
"stopped": "已停止",
"mode": "模式",
"url": "URL"
},
"apiKeys": {
"title": "API 密钥",
"description": "API 密钥允许外部工具通过 MCP 访问您的笔记。请保管好您的密钥。",
"generate": "生成新密钥",
"empty": "暂无 API 密钥。生成一个以开始使用。",
"active": "有效",
"revoked": "已撤销",
"revoke": "撤销",
"delete": "删除",
"createdAt": "创建于",
"lastUsed": "上次使用",
"never": "从未使用",
"confirmRevoke": "确定要撤销此密钥吗?使用该密钥的工具将失去访问权限。",
"confirmDelete": "确定要永久删除此密钥吗?"
},
"createDialog": {
"title": "生成 API 密钥",
"description": "创建新的 API 密钥以将外部工具连接到您的笔记。",
"nameLabel": "密钥名称",
"namePlaceholder": "例如Claude Code、Cursor、N8N",
"generating": "生成中...",
"generate": "生成",
"successTitle": "API 密钥已生成",
"successDescription": "请立即复制您的 API 密钥。之后将无法再次查看。",
"copy": "复制",
"copied": "已复制!",
"done": "完成"
},
"configInstructions": {
"title": "配置说明",
"description": "使用您的 API 密钥配置这些工具。",
"claudeCode": {
"title": "Claude Code",
"description": "将以下内容添加到您的 Claude Code MCP 配置文件中:"
},
"cursor": {
"title": "Cursor",
"description": "将以下内容添加到您的 Cursor MCP 设置中:"
},
"n8n": {
"title": "N8N",
"description": "在您的 N8N MCP 节点中使用以下凭据:"
}
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -275,6 +275,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
fontSize: 'fontSize',
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes',
notesViewMode: 'notesViewMode',
emailNotifications: 'emailNotifications',
desktopNotifications: 'desktopNotifications',
anonymousAnalytics: 'anonymousAnalytics'

View File

@@ -13554,6 +13554,7 @@ export namespace Prisma {
fontSize: string | null
demoMode: boolean | null
showRecentNotes: boolean | null
notesViewMode: string | null
emailNotifications: boolean | null
desktopNotifications: boolean | null
anonymousAnalytics: boolean | null
@@ -13571,6 +13572,7 @@ export namespace Prisma {
fontSize: string | null
demoMode: boolean | null
showRecentNotes: boolean | null
notesViewMode: string | null
emailNotifications: boolean | null
desktopNotifications: boolean | null
anonymousAnalytics: boolean | null
@@ -13588,6 +13590,7 @@ export namespace Prisma {
fontSize: number
demoMode: number
showRecentNotes: number
notesViewMode: number
emailNotifications: number
desktopNotifications: number
anonymousAnalytics: number
@@ -13607,6 +13610,7 @@ export namespace Prisma {
fontSize?: true
demoMode?: true
showRecentNotes?: true
notesViewMode?: true
emailNotifications?: true
desktopNotifications?: true
anonymousAnalytics?: true
@@ -13624,6 +13628,7 @@ export namespace Prisma {
fontSize?: true
demoMode?: true
showRecentNotes?: true
notesViewMode?: true
emailNotifications?: true
desktopNotifications?: true
anonymousAnalytics?: true
@@ -13641,6 +13646,7 @@ export namespace Prisma {
fontSize?: true
demoMode?: true
showRecentNotes?: true
notesViewMode?: true
emailNotifications?: true
desktopNotifications?: true
anonymousAnalytics?: true
@@ -13731,6 +13737,7 @@ export namespace Prisma {
fontSize: string
demoMode: boolean
showRecentNotes: boolean
notesViewMode: string
emailNotifications: boolean
desktopNotifications: boolean
anonymousAnalytics: boolean
@@ -13765,6 +13772,7 @@ export namespace Prisma {
fontSize?: boolean
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: boolean
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -13783,6 +13791,7 @@ export namespace Prisma {
fontSize?: boolean
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: boolean
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -13801,6 +13810,7 @@ export namespace Prisma {
fontSize?: boolean
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: boolean
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -13830,6 +13840,10 @@ export namespace Prisma {
fontSize: string
demoMode: boolean
showRecentNotes: boolean
/**
* "masonry" = cartes Muuri ; "list" = liste classique
*/
notesViewMode: string
emailNotifications: boolean
desktopNotifications: boolean
anonymousAnalytics: boolean
@@ -14238,6 +14252,7 @@ export namespace Prisma {
readonly fontSize: FieldRef<"UserAISettings", 'String'>
readonly demoMode: FieldRef<"UserAISettings", 'Boolean'>
readonly showRecentNotes: FieldRef<"UserAISettings", 'Boolean'>
readonly notesViewMode: FieldRef<"UserAISettings", 'String'>
readonly emailNotifications: FieldRef<"UserAISettings", 'Boolean'>
readonly desktopNotifications: FieldRef<"UserAISettings", 'Boolean'>
readonly anonymousAnalytics: FieldRef<"UserAISettings", 'Boolean'>
@@ -14771,6 +14786,7 @@ export namespace Prisma {
fontSize: 'fontSize',
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes',
notesViewMode: 'notesViewMode',
emailNotifications: 'emailNotifications',
desktopNotifications: 'desktopNotifications',
anonymousAnalytics: 'anonymousAnalytics'
@@ -15817,6 +15833,7 @@ export namespace Prisma {
fontSize?: StringFilter<"UserAISettings"> | string
demoMode?: BoolFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolFilter<"UserAISettings"> | boolean
notesViewMode?: StringFilter<"UserAISettings"> | string
emailNotifications?: BoolFilter<"UserAISettings"> | boolean
desktopNotifications?: BoolFilter<"UserAISettings"> | boolean
anonymousAnalytics?: BoolFilter<"UserAISettings"> | boolean
@@ -15835,6 +15852,7 @@ export namespace Prisma {
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder
desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder
@@ -15856,6 +15874,7 @@ export namespace Prisma {
fontSize?: StringFilter<"UserAISettings"> | string
demoMode?: BoolFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolFilter<"UserAISettings"> | boolean
notesViewMode?: StringFilter<"UserAISettings"> | string
emailNotifications?: BoolFilter<"UserAISettings"> | boolean
desktopNotifications?: BoolFilter<"UserAISettings"> | boolean
anonymousAnalytics?: BoolFilter<"UserAISettings"> | boolean
@@ -15874,6 +15893,7 @@ export namespace Prisma {
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder
desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder
@@ -15897,6 +15917,7 @@ export namespace Prisma {
fontSize?: StringWithAggregatesFilter<"UserAISettings"> | string
demoMode?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
notesViewMode?: StringWithAggregatesFilter<"UserAISettings"> | string
emailNotifications?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
desktopNotifications?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
anonymousAnalytics?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
@@ -16978,6 +16999,7 @@ export namespace Prisma {
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -16996,6 +17018,7 @@ export namespace Prisma {
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -17012,6 +17035,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17030,6 +17054,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17047,6 +17072,7 @@ export namespace Prisma {
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -17063,6 +17089,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17080,6 +17107,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -17946,6 +17974,7 @@ export namespace Prisma {
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder
desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder
@@ -17963,6 +17992,7 @@ export namespace Prisma {
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder
desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder
@@ -17980,6 +18010,7 @@ export namespace Prisma {
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
notesViewMode?: SortOrder
emailNotifications?: SortOrder
desktopNotifications?: SortOrder
anonymousAnalytics?: SortOrder
@@ -19613,6 +19644,7 @@ export namespace Prisma {
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -19629,6 +19661,7 @@ export namespace Prisma {
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
notesViewMode?: string
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
@@ -19947,6 +19980,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean
@@ -19963,6 +19997,7 @@ export namespace Prisma {
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
notesViewMode?: StringFieldUpdateOperationsInput | string
emailNotifications?: BoolFieldUpdateOperationsInput | boolean
desktopNotifications?: BoolFieldUpdateOperationsInput | boolean
anonymousAnalytics?: BoolFieldUpdateOperationsInput | boolean

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-c6853a2d560fc459913c7f241f4427cd8f8957f5474f01e1a2cabb8c1f55d4d8",
"name": "prisma-client-2331c58c0b3910e5ab8251136c7336826e2ba4756d13e7c07da246b228c31c54",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@@ -229,6 +229,8 @@ model UserAISettings {
fontSize String @default("medium")
demoMode Boolean @default(false)
showRecentNotes Boolean @default(true)
/// "masonry" = cartes Muuri ; "list" = liste classique
notesViewMode String @default("masonry")
emailNotifications Boolean @default(false)
desktopNotifications Boolean @default(false)
anonymousAnalytics Boolean @default(false)

View File

@@ -275,6 +275,7 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
fontSize: 'fontSize',
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes',
notesViewMode: 'notesViewMode',
emailNotifications: 'emailNotifications',
desktopNotifications: 'desktopNotifications',
anonymousAnalytics: 'anonymousAnalytics'

Binary file not shown.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserAISettings" ADD COLUMN "notesViewMode" TEXT NOT NULL DEFAULT 'masonry';

View File

@@ -229,6 +229,8 @@ model UserAISettings {
fontSize String @default("medium")
demoMode Boolean @default(false)
showRecentNotes Boolean @default(true)
/// "masonry" = grille cartes Muuri ; "tabs" = onglets + panneau (type OneNote). Ancienne valeur "list" migrée vers "tabs" en lecture.
notesViewMode String @default("masonry")
emailNotifications Boolean @default(false)
desktopNotifications Boolean @default(false)
anonymousAnalytics Boolean @default(false)

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB