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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
26
keep-notes/app/(main)/settings/loading.tsx
Normal file
26
keep-notes/app/(main)/settings/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
keep-notes/app/(main)/settings/mcp/page.tsx
Normal file
23
keep-notes/app/(main)/settings/mcp/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
167
keep-notes/app/actions/mcp-keys.ts
Normal file
167
keep-notes/app/actions/mcp-keys.ts
Normal 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 }
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
30
keep-notes/app/api/ai/translate/route.ts
Normal file
30
keep-notes/app/api/ai/translate/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user