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

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