fix: unify theme system - fix theme switching persistence

- Unified localStorage key to 'theme-preference' across all components
- Fixed header.tsx using wrong localStorage key ('theme' instead of 'theme-preference')
- Added localStorage hybrid persistence for instant theme changes
- Removed router.refresh() which was causing stale data revert
- Replaced Blue theme with Sepia
- Consolidated auth() calls to prevent race conditions
- Updated UserSettingsData types to include all themes
This commit is contained in:
2026-01-18 22:33:41 +01:00
parent ef60dafd73
commit ddb67ba9e5
306 changed files with 59580 additions and 6063 deletions

View File

@@ -1,13 +1,19 @@
'use client';
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-zinc-950">
<div className="w-full max-w-md p-4">
{children}
<LanguageProvider initialLanguage="fr">
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-zinc-950">
<div className="w-full max-w-md p-4">
{children}
</div>
</div>
</div>
</LanguageProvider>
);
}

View File

@@ -0,0 +1,127 @@
import { AdminMetrics } from '@/components/admin-metrics'
import { Button } from '@/components/ui/button'
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
export default async function AdminAIPage() {
// Mock AI metrics - in a real app, these would come from analytics
const aiMetrics = [
{
title: 'Total Requests',
value: '856',
trend: { value: 12, isPositive: true },
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
{
title: 'Success Rate',
value: '98.5%',
trend: { value: 2, isPositive: true },
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Avg Response Time',
value: '1.2s',
trend: { value: 5, isPositive: true },
icon: <Activity className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
},
{
title: 'Active Features',
value: '6',
icon: <Settings className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
},
]
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
AI Management
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Monitor and configure AI features
</p>
</div>
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
Configure
</Button>
</div>
<AdminMetrics metrics={aiMetrics} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Active AI Features
</h2>
<div className="space-y-3">
{[
'Title Suggestions',
'Semantic Search',
'Paragraph Reformulation',
'Memory Echo',
'Language Detection',
'Auto Labeling',
].map((feature) => (
<div
key={feature}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
>
<span className="text-sm text-gray-900 dark:text-white">
{feature}
</span>
<span className="px-2 py-1 text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900 rounded-full">
Active
</span>
</div>
))}
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
AI Provider Status
</h2>
<div className="space-y-3">
{[
{ name: 'OpenAI', status: 'Connected', requests: '642' },
{ name: 'Ollama', status: 'Available', requests: '214' },
].map((provider) => (
<div
key={provider.name}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg"
>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{provider.name}
</p>
<p className="text-xs text-gray-600 dark:text-gray-400">
{provider.requests} requests
</p>
</div>
<span
className={`px-2 py-1 text-xs font-medium rounded-full ${
provider.status === 'Connected'
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
: 'text-blue-700 dark:text-blue-400 bg-blue-100 dark:bg-blue-900'
}`}
>
{provider.status}
</span>
</div>
))}
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Recent AI Requests
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent AI requests will be displayed here.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { AdminSidebar } from '@/components/admin-sidebar'
import { AdminContentArea } from '@/components/admin-content-area'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if ((session?.user as any)?.role !== 'ADMIN') {
redirect('/')
}
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-zinc-950">
<AdminSidebar />
<AdminContentArea>{children}</AdminContentArea>
</div>
)
}

View File

@@ -1,39 +1,58 @@
import { getUsers } from '@/app/actions/admin'
import { UserList } from './user-list'
import { CreateUserDialog } from './create-user-dialog'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Settings } from 'lucide-react'
import { AdminPageHeader, SettingsButton } from '@/components/admin-page-header'
import { AdminMetrics } from '@/components/admin-metrics'
import { Users, Activity, Database, Zap } from 'lucide-react'
export default async function AdminPage() {
const session = await auth()
if ((session?.user as any)?.role !== 'ADMIN') {
redirect('/')
}
const users = await getUsers()
// Mock metrics data - in a real app, these would come from analytics
const metrics = [
{
title: 'Total Users',
value: users.length,
trend: { value: 12, isPositive: true },
icon: <Users className="h-5 w-5 text-blue-600 dark:text-blue-400" />,
},
{
title: 'Active Sessions',
value: '24',
trend: { value: 8, isPositive: true },
icon: <Activity className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Total Notes',
value: '1,234',
trend: { value: 24, isPositive: true },
icon: <Database className="h-5 w-5 text-purple-600 dark:text-purple-400" />,
},
{
title: 'AI Requests',
value: '856',
trend: { value: 5, isPositive: false },
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
]
return (
<div className="container mx-auto py-10 px-4">
<div className="flex justify-between items-center mb-8">
<AdminPageHeader />
<div className="flex gap-2">
<Link href="/admin/settings">
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
<SettingsButton />
</Button>
</Link>
<CreateUserDialog />
</div>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Dashboard
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Overview of your application metrics
</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800">
<UserList initialUsers={users} />
<AdminMetrics metrics={metrics} />
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Recent Activity
</h2>
<p className="text-gray-600 dark:text-gray-400">
Recent activity will be displayed here.
</p>
</div>
</div>
)

View File

@@ -1,21 +1,23 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { getSystemConfig } from '@/app/actions/admin-settings'
import { AdminSettingsForm } from './admin-settings-form'
export default async function AdminSettingsPage() {
const session = await auth()
if ((session?.user as any)?.role !== 'ADMIN') {
redirect('/')
}
const config = await getSystemConfig()
return (
<div className="container max-w-4xl mx-auto py-10 px-4">
<h1 className="text-3xl font-bold mb-8">System Configuration</h1>
<AdminSettingsForm config={config} />
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Settings
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Configure application-wide settings
</p>
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800 p-6">
<AdminSettingsForm config={config} />
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { getUsers } from '@/app/actions/admin'
import { CreateUserDialog } from '../create-user-dialog'
import { UserList } from '../user-list'
import { Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
export default async function AdminUsersPage() {
const users = await getUsers()
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Users
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage application users and permissions
</p>
</div>
<CreateUserDialog />
</div>
<div className="bg-white dark:bg-zinc-900 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-800">
<UserList initialUsers={users} />
</div>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import { HeaderWrapper } from "@/components/header-wrapper";
import { Sidebar } from "@/components/sidebar";
import { ProvidersWrapper } from "@/components/providers-wrapper";
import { auth } from "@/auth";
import { detectUserLanguage } from "@/lib/i18n/detect-user-language";
export default async function MainLayout({
children,
@@ -8,16 +10,25 @@ export default async function MainLayout({
children: React.ReactNode;
}>) {
const session = await auth();
const initialLanguage = await detectUserLanguage();
return (
<div className="flex h-screen flex-col">
<HeaderWrapper user={session?.user} />
<div className="flex flex-1 overflow-hidden">
<Sidebar className="shrink-0 border-r overflow-y-auto" user={session?.user} />
<main className="flex-1 overflow-y-auto">
{children}
</main>
<ProvidersWrapper initialLanguage={initialLanguage}>
<div className="bg-background-light dark:bg-background-dark font-display text-slate-900 dark:text-white overflow-hidden h-screen flex flex-col">
{/* Top Navigation - Style Keep */}
<HeaderWrapper user={session?.user} />
{/* Main Layout */}
<div className="flex flex-1 overflow-hidden">
{/* Sidebar Navigation - Style Keep */}
<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">
{children}
</main>
</div>
</div>
</div>
</ProvidersWrapper>
);
}

View File

@@ -20,11 +20,16 @@ import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
import { useNotebooks } from '@/context/notebooks-context'
import { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
export default function HomePage() {
console.log('[HomePage] Component rendering')
const searchParams = useSearchParams()
const router = useRouter()
// Force re-render when search params change (for filtering)
const [notes, setNotes] = useState<Note[]>([])
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
const [recentNotes, setRecentNotes] = useState<Note[]>([])
@@ -227,21 +232,154 @@ export default function HomePage() {
loadNotes()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey, showRecentNotes]) // Intentionally omit 'labels' and 'semantic' to prevent reload when adding tags or from router.push
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<NoteInput onNoteCreated={handleNoteCreated} />
// Get notebooks context to display header
const { notebooks } = useNotebooks()
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
const [showNoteInput, setShowNoteInput] = useState(false)
{/* Batch Organization Button - Only show in Inbox with 5+ notes */}
{isInbox && !isLoading && notes.length >= 5 && (
<div className="mb-4 flex justify-end">
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="default"
className="gap-2"
>
<Wand2 className="h-4 w-4" />
Organiser avec l'IA ({notes.length})
</Button>
// Get icon component for header
const getNotebookIcon = (iconName: string) => {
const ICON_MAP: Record<string, any> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
'flight_takeoff': Plane,
}
return ICON_MAP[iconName] || Folder
}
// Handle Note Created to close the input if desired, or keep open
const handleNoteCreatedWrapper = (note: any) => {
handleNoteCreated(note)
setShowNoteInput(false)
}
// Helper for Breadcrumbs
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
<div className="flex items-center gap-2 text-sm text-gray-500 mb-1">
<span>Notebooks</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-blue-600">{notebookName}</span>
</div>
)
return (
<main className="w-full px-8 py-6 flex flex-col h-full">
{/* Notebook Specific Header */}
{currentNotebook ? (
<div className="flex flex-col gap-6 mb-8 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Breadcrumbs */}
<Breadcrumbs notebookName={currentNotebook.name} />
<div className="flex items-start justify-between">
{/* Title Section */}
<div className="flex items-center gap-5">
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-xl">
{(() => {
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
return (
<Icon
className={cn("w-8 h-8", !currentNotebook.color && "text-blue-600 dark:text-blue-400")}
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
/>
)
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
</div>
{/* Actions Section */}
<div className="flex items-center gap-3">
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
<Button
onClick={() => setShowNoteInput(!showNoteInput)}
className="h-10 px-6 rounded-full bg-blue-600 hover:bg-blue-700 text-white font-medium shadow-sm gap-2 transition-all"
>
<Plus className="w-5 h-5" />
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">
{/* Breadcrumbs Placeholder or just spacing */}
<div className="h-5 mb-1"></div>
<div className="flex items-start justify-between">
{/* Title Section */}
<div className="flex items-center gap-5">
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
<FileText className="w-8 h-8 text-blue-600" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">Notes</h1>
</div>
{/* Actions Section */}
<div className="flex items-center gap-3">
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
{/* AI Organization Button - Moved to Header */}
{isInbox && !isLoading && notes.length >= 5 && (
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="outline"
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
title="Organiser avec l'IA"
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">Organiser</span>
</Button>
)}
<Button
onClick={() => setShowNoteInput(!showNoteInput)}
className="h-10 px-6 rounded-full bg-blue-600 hover:bg-blue-700 text-white font-medium shadow-sm gap-2 transition-all"
>
<Plus className="w-5 h-5" />
Add Note
</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">
<NoteInput
onNoteCreated={handleNoteCreatedWrapper}
forceExpanded={true}
/>
</div>
)}

View File

@@ -9,136 +9,126 @@ export default function AboutSettingsPage() {
const buildDate = '2026-01-17'
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">About</h1>
<p className="text-gray-600 dark:text-gray-400">
Information about the application
</p>
</div>
<SettingsSection
title="Keep Notes"
icon={<span className="text-2xl">📝</span>}
description="A powerful note-taking application with AI-powered features"
>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium">Version</span>
<Badge variant="secondary">{version}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">Build Date</span>
<Badge variant="outline">{buildDate}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">Platform</span>
<Badge variant="outline">Web</Badge>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Features"
icon={<span className="text-2xl"></span>}
description="AI-powered capabilities"
>
<Card>
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>AI-powered title suggestions</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Semantic search with embeddings</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Paragraph reformulation</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Memory Echo daily insights</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Notebook organization</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Drag & drop note management</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Label system</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Multiple AI providers (OpenAI, Ollama)</span>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Technology Stack"
icon={<span className="text-2xl"></span>}
description="Built with modern technologies"
>
<Card>
<CardContent className="pt-6 space-y-2 text-sm">
<div><strong>Frontend:</strong> Next.js 16, React 19, TypeScript</div>
<div><strong>Backend:</strong> Next.js API Routes, Server Actions</div>
<div><strong>Database:</strong> SQLite (Prisma ORM)</div>
<div><strong>Authentication:</strong> NextAuth 5</div>
<div><strong>AI:</strong> Vercel AI SDK, OpenAI, Ollama</div>
<div><strong>UI:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
<div><strong>Testing:</strong> Playwright (E2E)</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Support"
icon={<span className="text-2xl">💬</span>}
description="Get help and feedback"
>
<Card>
<CardContent className="pt-6 space-y-4">
<div>
<p className="font-medium mb-2">Documentation</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Check the documentation for detailed guides and tutorials.
</p>
</div>
<div>
<p className="font-medium mb-2">Report Issues</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Found a bug? Report it in the issue tracker.
</p>
</div>
<div>
<p className="font-medium mb-2">Feedback</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
We value your feedback! Share your thoughts and suggestions.
</p>
</div>
</CardContent>
</Card>
</SettingsSection>
</main>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">About</h1>
<p className="text-gray-600 dark:text-gray-400">
Information about the application
</p>
</div>
<SettingsSection
title="Keep Notes"
icon={<span className="text-2xl">📝</span>}
description="A powerful note-taking application with AI-powered features"
>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium">Version</span>
<Badge variant="secondary">{version}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">Build Date</span>
<Badge variant="outline">{buildDate}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">Platform</span>
<Badge variant="outline">Web</Badge>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Features"
icon={<span className="text-2xl"></span>}
description="AI-powered capabilities"
>
<Card>
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>AI-powered title suggestions</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Semantic search with embeddings</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Paragraph reformulation</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Memory Echo daily insights</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Notebook organization</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Drag & drop note management</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Label system</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>Multiple AI providers (OpenAI, Ollama)</span>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Technology Stack"
icon={<span className="text-2xl"></span>}
description="Built with modern technologies"
>
<Card>
<CardContent className="pt-6 space-y-2 text-sm">
<div><strong>Frontend:</strong> Next.js 16, React 19, TypeScript</div>
<div><strong>Backend:</strong> Next.js API Routes, Server Actions</div>
<div><strong>Database:</strong> SQLite (Prisma ORM)</div>
<div><strong>Authentication:</strong> NextAuth 5</div>
<div><strong>AI:</strong> Vercel AI SDK, OpenAI, Ollama</div>
<div><strong>UI:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
<div><strong>Testing:</strong> Playwright (E2E)</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title="Support"
icon={<span className="text-2xl">💬</span>}
description="Get help and feedback"
>
<Card>
<CardContent className="pt-6 space-y-4">
<div>
<p className="font-medium mb-2">Documentation</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Check the documentation for detailed guides and tutorials.
</p>
</div>
<div>
<p className="font-medium mb-2">Report Issues</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Found a bug? Report it in the issue tracker.
</p>
</div>
<div>
<p className="font-medium mb-2">Feedback</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
We value your feedback! Share your thoughts and suggestions.
</p>
</div>
</CardContent>
</Card>
</SettingsSection>
</div>
)
}

View File

@@ -14,7 +14,7 @@ export default async function AISettingsPage() {
const settings = await getAISettings()
return (
<div className="container mx-auto py-8 max-w-4xl">
<div className="space-y-6">
<AISettingsHeader />
<AISettingsPanel initialSettings={settings} />
</div>

View File

@@ -0,0 +1,103 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
// Import actions directly
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
interface AppearanceSettingsFormProps {
initialTheme: string
initialFontSize: string
}
export function AppearanceSettingsForm({ initialTheme, initialFontSize }: AppearanceSettingsFormProps) {
const router = useRouter()
const [theme, setTheme] = useState(initialTheme)
const [fontSize, setFontSize] = useState(initialFontSize)
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')
}
// Save to DB (no need for router.refresh - localStorage handles immediate visuals)
await updateUser({ theme: value as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' })
}
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 updateAI({ fontSize: value as any })
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Appearance</h1>
<p className="text-gray-600 dark:text-gray-400">
Customize look and feel of application
</p>
</div>
<SettingsSection
title="Theme"
icon={<span className="text-2xl">🎨</span>}
description="Choose your preferred color scheme"
>
<SettingSelect
label="Color Scheme"
description="Select app's visual theme"
value={theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'auto', label: 'Auto (system)' },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title="Typography"
icon={<span className="text-2xl">📝</span>}
description="Adjust text size for better readability"
>
<SettingSelect
label="Font Size"
description="Adjust size of text throughout app"
value={fontSize}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -1,79 +1,111 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { SettingsNav, SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
import { updateUserSettings, getUserSettings } from '@/app/actions/user-settings'
export default function AppearanceSettingsPage() {
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)
// TODO: Implement theme persistence
console.log('Theme:', 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' })
}
const handleFontSizeChange = async (value: string) => {
setFontSize(value)
// TODO: Implement font size persistence
// 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 })
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Appearance</h1>
<p className="text-gray-600 dark:text-gray-400">
Customize the look and feel of the application
</p>
</div>
<SettingsSection
title="Theme"
icon={<span className="text-2xl">🎨</span>}
description="Choose your preferred color scheme"
>
<SettingSelect
label="Color Scheme"
description="Select the app's visual theme"
value={theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto (system)' },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title="Typography"
icon={<span className="text-2xl">📝</span>}
description="Adjust text size for better readability"
>
<SettingSelect
label="Font Size"
description="Adjust the size of text throughout the app"
value={fontSize}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
</main>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Appearance</h1>
<p className="text-gray-600 dark:text-gray-400">
Customize look and feel of application
</p>
</div>
<SettingsSection
title="Theme"
icon={<span className="text-2xl">🎨</span>}
description="Choose your preferred color scheme"
>
<SettingSelect
label="Color Scheme"
description="Select app's visual theme"
value={theme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'auto', label: 'Auto (system)' },
]}
onChange={handleThemeChange}
/>
</SettingsSection>
<SettingsSection
title="Typography"
icon={<span className="text-2xl">📝</span>}
description="Adjust text size for better readability"
>
<SettingSelect
label="Font Size"
description="Adjust size of text throughout app"
value={fontSize}
options={[
{ value: 'small', label: 'Small' },
{ value: 'medium', label: 'Medium' },
{ value: 'large', label: 'Large' },
]}
onChange={handleFontSizeChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -89,112 +89,102 @@ export default function DataSettingsPage() {
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Data Management</h1>
<p className="text-gray-600 dark:text-gray-400">
Export, import, or manage your data
</p>
</div>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<SettingsSection
title="Export Data"
icon={<span className="text-2xl">💾</span>}
description="Download your notes as a JSON file"
>
<div className="flex items-center justify-between py-4">
<div>
<h1 className="text-3xl font-bold mb-2">Data Management</h1>
<p className="text-gray-600 dark:text-gray-400">
Export, import, or manage your data
<p className="font-medium">Export All Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download all your notes in JSON format
</p>
</div>
<SettingsSection
title="Export Data"
icon={<span className="text-2xl">💾</span>}
description="Download your notes as a JSON file"
<Button
onClick={handleExport}
disabled={isExporting}
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">Export All Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download all your notes in JSON format
</p>
</div>
<Button
onClick={handleExport}
disabled={isExporting}
>
{isExporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</div>
</SettingsSection>
{isExporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</div>
</SettingsSection>
<SettingsSection
title="Import Data"
icon={<span className="text-2xl">📥</span>}
description="Import notes from a JSON file"
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">Import Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Upload a JSON file to import notes
</p>
</div>
<div>
<input
type="file"
accept=".json"
onChange={handleImport}
disabled={isImporting}
className="hidden"
id="import-file"
/>
<Button
onClick={() => document.getElementById('import-file')?.click()}
disabled={isImporting}
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
</SettingsSection>
<SettingsSection
title="Import Data"
icon={<span className="text-2xl">📥</span>}
description="Import notes from a JSON file"
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">Import Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Upload a JSON file to import notes
</p>
</div>
<div>
<input
type="file"
accept=".json"
onChange={handleImport}
disabled={isImporting}
className="hidden"
id="import-file"
/>
<Button
onClick={() => document.getElementById('import-file')?.click()}
disabled={isImporting}
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
</SettingsSection>
<SettingsSection
title="Danger Zone"
icon={<span className="text-2xl"></span>}
description="Permanently delete your data"
<SettingsSection
title="Danger Zone"
icon={<span className="text-2xl"></span>}
description="Permanently delete your data"
>
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
<div>
<p className="font-medium text-red-600 dark:text-red-400">Delete All Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone
</p>
</div>
<Button
variant="destructive"
onClick={handleDeleteAll}
disabled={isDeleting}
>
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
<div>
<p className="font-medium text-red-600 dark:text-red-400">Delete All Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone
</p>
</div>
<Button
variant="destructive"
onClick={handleDeleteAll}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{isDeleting ? 'Deleting...' : 'Delete All'}
</Button>
</div>
</SettingsSection>
</main>
</div>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{isDeleting ? 'Deleting...' : 'Delete All'}
</Button>
</div>
</SettingsSection>
</div>
)
}

View File

@@ -1,107 +1,124 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect, SettingsSearch } from '@/components/settings'
import { useState, useEffect } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { updateAISettings, getAISettings } from '@/app/actions/ai-settings'
export default function GeneralSettingsPage() {
const { t } = useLanguage()
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)
await updateAISettings({ preferredLanguage: value as any })
}
const handleNotificationsChange = async (enabled: boolean) => {
// TODO: Implement notifications setting
console.log('Notifications:', enabled)
const handleEmailNotificationsChange = async (enabled: boolean) => {
setEmailNotifications(enabled)
await updateAISettings({ emailNotifications: enabled })
}
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="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">General Settings</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure basic application preferences
</p>
</div>
<SettingsSearch onSearch={(query) => console.log('Search:', query)} />
<SettingsSection
title="Language & Region"
icon={<span className="text-2xl">🌍</span>}
description="Choose your preferred language and regional settings"
>
<SettingSelect
label="Language"
description="Select the interface language"
value={language}
options={[
{ value: 'auto', label: 'Auto-detect' },
{ 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="Notifications"
icon={<span className="text-2xl">🔔</span>}
description="Manage how and when you receive notifications"
>
<SettingToggle
label="Email Notifications"
description="Receive email updates about your notes"
checked={false}
onChange={handleNotificationsChange}
/>
<SettingToggle
label="Desktop Notifications"
description="Show notifications in your browser"
checked={false}
onChange={handleNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title="Privacy"
icon={<span className="text-2xl">🔒</span>}
description="Control your privacy settings"
>
<SettingToggle
label="Anonymous Analytics"
description="Help improve the app with anonymous usage data"
checked={false}
onChange={handleNotificationsChange}
/>
</SettingsSection>
</main>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">General Settings</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure basic application preferences
</p>
</div>
<SettingsSection
title="Language & Region"
icon={<span className="text-2xl">🌍</span>}
description="Choose your preferred language and regional settings"
>
<SettingSelect
label="Language"
description="Select interface language"
value={language}
options={[
{ value: 'auto', label: 'Auto-detect' },
{ 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="Notifications"
icon={<span className="text-2xl">🔔</span>}
description="Manage how and when you receive notifications"
>
<SettingToggle
label="Email Notifications"
description="Receive email updates about your notes"
checked={emailNotifications}
onChange={handleEmailNotificationsChange}
/>
<SettingToggle
label="Desktop Notifications"
description="Show notifications in your browser"
checked={desktopNotifications}
onChange={handleDesktopNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title="Privacy"
icon={<span className="text-2xl">🔒</span>}
description="Control your privacy settings"
>
<SettingToggle
label="Anonymous Analytics"
description="Help improve app with anonymous usage data"
checked={anonymousAnalytics}
onChange={handleAnonymousAnalyticsChange}
/>
</SettingsSection>
</div>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
import { SettingsNav } from '@/components/settings'
export default function SettingsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
{children}
</main>
</div>
</div>
)
}

View File

@@ -78,146 +78,136 @@ export default function SettingsPage() {
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Settings</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure your application settings
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Settings</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure your application settings
</p>
</div>
{/* Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link href="/settings/ai">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<BrainCircuit className="h-6 w-6 text-purple-500 mb-2" />
<h3 className="font-semibold">AI Settings</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI features and provider
</p>
</div>
{/* Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link href="/settings/ai">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<BrainCircuit className="h-6 w-6 text-purple-500 mb-2" />
<h3 className="font-semibold">AI Settings</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI features and provider
</p>
</div>
</Link>
<Link href="/settings/profile">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<RefreshCw className="h-6 w-6 text-blue-500 mb-2" />
<h3 className="font-semibold">Profile Settings</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Manage your account and preferences
</p>
</div>
</Link>
</Link>
<Link href="/settings/profile">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<RefreshCw className="h-6 w-6 text-blue-500 mb-2" />
<h3 className="font-semibold">Profile Settings</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Manage your account and preferences
</p>
</div>
</Link>
</div>
{/* AI Diagnostics */}
<SettingsSection
title="AI Diagnostics"
icon={<span className="text-2xl">🔍</span>}
description="Check your AI provider connection status"
>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">Configured Provider</p>
<p className="text-lg font-mono">{config?.provider || '...'}</p>
</div>
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">API Status</p>
<div className="flex items-center gap-2">
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
<span className={`text-sm font-medium ${
status === 'success' ? 'text-green-600 dark:text-green-400' :
status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-gray-600'
}`}>
{status === 'success' ? 'Operational' :
status === 'error' ? 'Error' :
'Checking...'}
</span>
</div>
</div>
{/* AI Diagnostics */}
<SettingsSection
title="AI Diagnostics"
icon={<span className="text-2xl">🔍</span>}
description="Check your AI provider connection status"
>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">Configured Provider</p>
<p className="text-lg font-mono">{config?.provider || '...'}</p>
</div>
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">API Status</p>
<div className="flex items-center gap-2">
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
<span className={`text-sm font-medium ${status === 'success' ? 'text-green-600 dark:text-green-400' :
status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-gray-600'
}`}>
{status === 'success' ? 'Operational' :
status === 'error' ? 'Error' :
'Checking...'}
</span>
</div>
</div>
</div>
{result && (
<div className="space-y-2 mt-4">
<h3 className="text-sm font-medium">Test Details:</h3>
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error'
? 'bg-red-50 text-red-900 border border-red-200 dark:bg-red-950 dark:text-red-100 dark:border-red-900'
: 'bg-slate-950 text-slate-50'
}`}>
<pre>{JSON.stringify(result, null, 2)}</pre>
</div>
{result && (
<div className="space-y-2 mt-4">
<h3 className="text-sm font-medium">Test Details:</h3>
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${
status === 'error'
? 'bg-red-50 text-red-900 border border-red-200 dark:bg-red-950 dark:text-red-100 dark:border-red-900'
: 'bg-slate-950 text-slate-50'
}`}>
<pre>{JSON.stringify(result, null, 2)}</pre>
</div>
{status === 'error' && (
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
<p className="font-bold">Troubleshooting Tips:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Check that Ollama is running (<code className="bg-red-100 dark:bg-red-900 px-1 rounded">ollama list</code>)</li>
<li>Check URL (http://localhost:11434)</li>
<li>Verify model (e.g., granite4:latest) is downloaded</li>
<li>Check Next.js server terminal for more logs</li>
</ul>
</div>
)}
{status === 'error' && (
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
<p className="font-bold">Troubleshooting Tips:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Check that Ollama is running (<code className="bg-red-100 dark:bg-red-900 px-1 rounded">ollama list</code>)</li>
<li>Check URL (http://localhost:11434)</li>
<li>Verify model (e.g., granite4:latest) is downloaded</li>
<li>Check Next.js server terminal for more logs</li>
</ul>
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Test Connection
</Button>
<div className="mt-4 flex justify-end">
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Test Connection
</Button>
</div>
</SettingsSection>
{/* Maintenance */}
<SettingsSection
title="Maintenance"
icon={<span className="text-2xl">🔧</span>}
description="Tools to maintain your database health"
>
<div className="space-y-4 py-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Clean Orphan Tags
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Remove tags that are no longer used by any notes
</p>
</div>
</SettingsSection>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
Clean
</Button>
</div>
{/* Maintenance */}
<SettingsSection
title="Maintenance"
icon={<span className="text-2xl">🔧</span>}
description="Tools to maintain your database health"
>
<div className="space-y-4 py-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Clean Orphan Tags
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Remove tags that are no longer used by any notes
</p>
</div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
Clean
</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Semantic Indexing
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Generate vectors for all notes to enable intent-based search
</p>
</div>
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
Index All
</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Semantic Indexing
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Generate vectors for all notes to enable intent-based search
</p>
</div>
</SettingsSection>
</main>
</div>
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
Index All
</Button>
</div>
</div>
</SettingsSection>
</div>
)
}

View File

@@ -23,29 +23,25 @@ export default async function ProfilePage() {
redirect('/login')
}
// Get user AI settings for language preference and recent notes setting
// Get user AI settings
let userAISettings = { preferredLanguage: 'auto', showRecentNotes: false }
try {
const result = await prisma.$queryRaw<Array<{ preferredLanguage: string | null; showRecentNotes: number | null }>>`
SELECT preferredLanguage, showRecentNotes FROM UserAISettings WHERE userId = ${session.user.id}
`
if (result && result[0]) {
// Handle NULL values - if showRecentNotes is NULL, default to false
const showRecentNotesValue = result[0].showRecentNotes !== null && result[0].showRecentNotes !== undefined
? result[0].showRecentNotes === 1
: false
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (aiSettings) {
userAISettings = {
preferredLanguage: result[0].preferredLanguage || 'auto',
showRecentNotes: showRecentNotesValue
preferredLanguage: aiSettings.preferredLanguage || 'auto',
showRecentNotes: aiSettings.showRecentNotes ?? false
}
}
} catch (error) {
// Record doesn't exist, use defaults
console.error('Error fetching AI settings:', error)
}
return (
<div className="container max-w-2xl mx-auto py-10 px-4">
<div className="max-w-2xl">
<ProfilePageHeader />
<ProfileForm user={user} userAISettings={userAISettings} />

View File

@@ -125,7 +125,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
const handleShowRecentNotesChange = async (enabled: boolean) => {
setIsUpdatingRecentNotes(true)
const previousValue = showRecentNotes
try {
const result = await updateShowRecentNotes(enabled)
if (result?.error) {
@@ -209,56 +209,7 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('profile.displaySettings')}</CardTitle>
<CardDescription>{t('profile.displaySettingsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fontSize">{t('profile.fontSize')}</Label>
<Select
value={fontSize}
onValueChange={handleFontSizeChange}
disabled={isUpdatingFontSize}
>
<SelectTrigger id="fontSize">
<SelectValue placeholder={t('profile.selectFontSize')} />
</SelectTrigger>
<SelectContent>
{FONT_SIZES.map((size) => (
<SelectItem key={size.value} value={size.value}>
<span className="flex items-center gap-2">
<span>{size.label}</span>
<span className="text-xs text-muted-foreground">({size.size})</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{t('profile.fontSizeDescription')}
</p>
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="space-y-0.5">
<Label htmlFor="showRecentNotes" className="text-base font-medium">
{t('profile.showRecentNotes') || 'Afficher la section Récent'}
</Label>
<p className="text-sm text-muted-foreground">
{t('profile.showRecentNotesDescription') || 'Afficher les notes récentes (7 derniers jours) sur la page principale'}
</p>
</div>
<Switch
id="showRecentNotes"
checked={showRecentNotes}
onCheckedChange={handleShowRecentNotesChange}
disabled={isUpdatingRecentNotes}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>

View File

@@ -0,0 +1,23 @@
import { ArchiveHeader } from '@/components/archive-header'
import { Trash2 } from 'lucide-react'
export const dynamic = 'force-dynamic'
export default function TrashPage() {
// Currently, we don't have soft-delete implemented, so trash is always empty.
// This page exists to fix the 404 error and provide a placeholder.
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center text-gray-500">
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full mb-4">
<Trash2 className="w-12 h-12 text-gray-400" />
</div>
<h2 className="text-xl font-medium mb-2">La corbeille est vide</h2>
<p className="max-w-md text-sm opacity-80">
Les notes supprimées sont actuellement effacées définitivement.
</p>
</div>
</main>
)
}

View File

@@ -14,20 +14,29 @@ export type UserAISettingsData = {
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
showRecentNotes?: boolean
emailNotifications?: boolean
desktopNotifications?: boolean
anonymousAnalytics?: boolean
theme?: 'light' | 'dark' | 'auto'
fontSize?: 'small' | 'medium' | 'large'
}
/**
* Update AI settings for the current user
*/
export async function updateAISettings(settings: UserAISettingsData) {
console.log('[updateAISettings] Started with:', JSON.stringify(settings, null, 2))
const session = await auth()
console.log('[updateAISettings] Session User ID:', session?.user?.id)
if (!session?.user?.id) {
console.error('[updateAISettings] Unauthorized: No session or user ID')
throw new Error('Unauthorized')
}
try {
// Upsert settings (create if not exists, update if exists)
await prisma.userAISettings.upsert({
const result = await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
@@ -35,6 +44,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
},
update: settings
})
console.log('[updateAISettings] Database upsert successful:', result)
revalidatePath('/settings/ai')
revalidatePath('/')
@@ -49,11 +59,16 @@ export async function updateAISettings(settings: UserAISettingsData) {
/**
* Get AI settings for the current user
*/
export async function getAISettings() {
const session = await auth()
export async function getAISettings(userId?: string) {
let id = userId
if (!id) {
const session = await auth()
id = session?.user?.id
}
// Return defaults for non-logged-in users
if (!session?.user?.id) {
if (!id) {
return {
titleSuggestions: true,
semanticSearch: true,
@@ -63,33 +78,21 @@ export async function getAISettings() {
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
try {
// Use raw SQL query to get showRecentNotes until Prisma client is regenerated
const settingsRaw = await prisma.$queryRaw<Array<{
titleSuggestions: number
semanticSearch: number
paragraphRefactor: number
memoryEcho: number
memoryEchoFrequency: string
aiProvider: string
preferredLanguage: string
fontSize: string
demoMode: number
showRecentNotes: number
}>>`
SELECT titleSuggestions, semanticSearch, paragraphRefactor, memoryEcho,
memoryEchoFrequency, aiProvider, preferredLanguage, fontSize,
demoMode, showRecentNotes
FROM UserAISettings
WHERE userId = ${session.user.id}
`
const settings = await prisma.userAISettings.findUnique({
where: { userId: id }
})
// Return settings or defaults if not found
if (!settingsRaw || settingsRaw.length === 0) {
if (!settings) {
return {
titleSuggestions: true,
semanticSearch: true,
@@ -99,28 +102,30 @@ export async function getAISettings() {
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
const settings = settingsRaw[0]
// Type-cast database values to proper union types
// Handle NULL values - SQLite can return NULL for showRecentNotes if column was added later
const showRecentNotesValue = settings.showRecentNotes !== null && settings.showRecentNotes !== undefined
? settings.showRecentNotes === 1
: false
return {
titleSuggestions: settings.titleSuggestions === 1,
semanticSearch: settings.semanticSearch === 1,
paragraphRefactor: settings.paragraphRefactor === 1,
memoryEcho: settings.memoryEcho === 1,
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
paragraphRefactor: settings.paragraphRefactor,
memoryEcho: settings.memoryEcho,
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode === 1,
showRecentNotes: showRecentNotesValue
demoMode: settings.demoMode,
showRecentNotes: settings.showRecentNotes,
emailNotifications: settings.emailNotifications,
desktopNotifications: settings.desktopNotifications,
anonymousAnalytics: settings.anonymousAnalytics,
// theme: 'light' as const, // REMOVED: Should not be handled here or hardcoded
fontSize: (settings.fontSize || 'medium') as 'small' | 'medium' | 'large'
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -134,7 +139,12 @@ export async function getAISettings() {
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
}

View File

@@ -6,7 +6,8 @@ import { Note, CheckItem } from '@/lib/types'
import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
import { getSystemConfig, getConfigNumber, SEARCH_DEFAULTS } from '@/lib/config'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
// Helper function to parse JSON strings from database
function parseNote(dbNote: any): Note {
@@ -337,6 +338,41 @@ export async function createNote(data: {
console.error('Embedding generation failed:', e);
}
// AUTO-LABELING: If no labels provided and auto-labeling is enabled, suggest labels
let labelsToUse = data.labels || null;
if ((!labelsToUse || labelsToUse.length === 0) && data.notebookId) {
try {
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true);
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
if (autoLabelingEnabled) {
console.log('[AUTO-LABELING] Generating suggestions for new note in notebook:', data.notebookId);
const suggestions = await contextualAutoTagService.suggestLabels(
data.content,
data.notebookId,
session.user.id
);
// Apply suggestions with confidence >= threshold
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label);
if (appliedLabels.length > 0) {
labelsToUse = appliedLabels;
console.log(`[AUTO-LABELING] Applied ${appliedLabels.length} labels:`, appliedLabels);
} else {
console.log('[AUTO-LABELING] No suggestions met confidence threshold');
}
} else {
console.log('[AUTO-LABELING] Disabled in config');
}
} catch (error) {
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
// Continue without auto-labeling on error
}
}
const note = await prisma.note.create({
data: {
userId: session.user.id,
@@ -345,7 +381,7 @@ export async function createNote(data: {
color: data.color || 'default',
type: data.type || 'text',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
labels: data.labels ? JSON.stringify(data.labels) : null,
labels: labelsToUse ? JSON.stringify(labelsToUse) : null,
images: data.images ? JSON.stringify(data.images) : null,
links: data.links ? JSON.stringify(data.links) : null,
isArchived: data.isArchived || false,
@@ -360,8 +396,8 @@ export async function createNote(data: {
})
// Sync labels to ensure Label records exist
if (data.labels && data.labels.length > 0) {
await syncLabels(session.user.id, data.labels)
if (labelsToUse && labelsToUse.length > 0) {
await syncLabels(session.user.id, labelsToUse)
}
// Revalidate main page (handles both inbox and notebook views via query params)

View File

@@ -0,0 +1,232 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
})
const PasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export async function updateProfile(data: { name: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const validated = ProfileSchema.safeParse(data)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: validated.data.name },
})
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to update profile'] } }
}
}
export async function changePassword(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const rawData = {
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
}
const validated = PasswordSchema.safeParse(rawData)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
const { currentPassword, newPassword } = validated.data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user || !user.password) {
return { error: { _form: ['User not found'] } }
}
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
if (!passwordsMatch) {
return { error: { currentPassword: ['Incorrect current password'] } }
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
})
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to change password'] } }
}
}
export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

@@ -0,0 +1,232 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email().optional(), // Email change might require verification logic, keeping it simple for now or read-only
})
const PasswordSchema = z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z.string().min(6, "New password must be at least 6 characters"),
confirmPassword: z.string().min(6, "Confirm password must be at least 6 characters"),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export async function updateProfile(data: { name: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const validated = ProfileSchema.safeParse(data)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: validated.data.name },
})
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to update profile'] } }
}
}
export async function changePassword(formData: FormData) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const rawData = {
currentPassword: formData.get('currentPassword'),
newPassword: formData.get('newPassword'),
confirmPassword: formData.get('confirmPassword'),
}
const validated = PasswordSchema.safeParse(rawData)
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors }
}
const { currentPassword, newPassword } = validated.data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
})
if (!user || !user.password) {
return { error: { _form: ['User not found'] } }
}
const passwordsMatch = await bcrypt.compare(currentPassword, user.password)
if (!passwordsMatch) {
return { error: { currentPassword: ['Incorrect current password'] } }
}
const hashedPassword = await bcrypt.hash(newPassword, 10)
try {
await prisma.user.update({
where: { id: session.user.id },
data: { password: hashedPassword },
})
return { success: true }
} catch (error) {
return { error: { _form: ['Failed to change password'] } }
}
}
export async function updateTheme(theme: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.user.update({
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
}
}
export async function updateLanguage(language: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Update or create UserAISettings with the preferred language
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
preferredLanguage: language,
},
update: {
preferredLanguage: language,
},
})
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
console.error('Failed to update language:', error)
return { error: 'Failed to update language' }
}
}
export async function updateFontSize(fontSize: string) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Check if UserAISettings exists
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
let result
if (existing) {
// Update existing - only update fontSize field
result = await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
userId: session.user.id,
fontSize: fontSize,
// Set default values for required fields
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
console.error('[updateFontSize] Failed to update font size:', error)
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

@@ -146,7 +146,7 @@ export async function updateFontSize(fontSize: string) {
where: { userId: session.user.id },
data: { fontSize: fontSize }
})
} else {
} else {
// Create new with all required fields
result = await prisma.userAISettings.create({
data: {
@@ -163,7 +163,7 @@ export async function updateFontSize(fontSize: string) {
showRecentNotes: false
}
})
}
}
revalidatePath('/')
revalidatePath('/settings/profile')
@@ -179,49 +179,18 @@ export async function updateShowRecentNotes(showRecentNotes: boolean) {
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
showRecentNotes,
// Defaults will be used for other fields
},
update: {
showRecentNotes,
},
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }

View File

@@ -0,0 +1,71 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight'
}
/**
* Update user settings (theme, etc.)
*/
export async function updateUserSettings(settings: UserSettingsData) {
console.log('[updateUserSettings] Started with:', settings)
const session = await auth()
if (!session?.user?.id) {
console.error('[updateUserSettings] Unauthorized')
throw new Error('Unauthorized')
}
try {
const result = await prisma.user.update({
where: { id: session.user.id },
data: settings
})
console.log('[updateUserSettings] Success:', result)
revalidatePath('/', 'layout')
return { success: true }
} catch (error) {
console.error('Error updating user settings:', error)
throw new Error('Failed to update user settings')
}
}
/**
* Get user settings for current user
*/
export async function getUserSettings(userId?: string) {
let id = userId
if (!id) {
const session = await auth()
id = session?.user?.id
}
if (!id) {
return {
theme: 'light' as const
}
}
try {
const user = await prisma.user.findUnique({
where: { id },
select: { theme: true }
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto'
}
} catch (error) {
console.error('Error getting user settings:', error)
return {
theme: 'light' as const
}
}
}

View File

@@ -5,12 +5,49 @@
@custom-variant dark (&:is(.dark *));
/* Custom breakpoints for desktop design (matching code.html reference) */
@theme {
/* Desktop breakpoints: 1024px (min), 1440px (large), 1920px (ultra-wide) */
--breakpoint-desktop: 1024px;
--breakpoint-large-desktop: 1440px;
--breakpoint-ultra-wide: 1920px;
/* Custom colors matching Keep design */
--color-primary: #356ac0;
--color-background-light: #f7f7f8;
--color-background-dark: #1a1d23;
}
/* Custom scrollbar for better aesthetics - Keep style */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
/* Custom Prose overrides for compact notes */
@utility prose-compact {
& :where(h1, h2, h3, h4) {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
& :where(p, ul, ol, li) {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
@@ -150,6 +187,35 @@
--ring: oklch(0.7 0.15 260);
}
[data-theme='blue'] {
--background: oklch(0.96 0.02 240);
--foreground: oklch(0.15 0.05 240);
--card: oklch(0.98 0.01 240);
--card-foreground: oklch(0.15 0.05 240);
--popover: oklch(0.98 0.01 240);
--popover-foreground: oklch(0.15 0.05 240);
--primary: oklch(0.45 0.15 240);
--primary-foreground: oklch(0.98 0.01 240);
--secondary: oklch(0.92 0.03 240);
--secondary-foreground: oklch(0.15 0.05 240);
--muted: oklch(0.92 0.03 240);
--muted-foreground: oklch(0.5 0.05 240);
--accent: oklch(0.92 0.03 240);
--accent-foreground: oklch(0.15 0.05 240);
--destructive: oklch(0.6 0.2 25);
--border: oklch(0.85 0.05 240);
--input: oklch(0.85 0.05 240);
--ring: oklch(0.45 0.15 240);
--sidebar: oklch(0.95 0.02 240);
--sidebar-foreground: oklch(0.15 0.05 240);
--sidebar-primary: oklch(0.45 0.15 240);
--sidebar-primary-foreground: oklch(0.98 0.01 240);
--sidebar-accent: oklch(0.92 0.03 240);
--sidebar-accent-foreground: oklch(0.15 0.05 240);
--sidebar-border: oklch(0.85 0.05 240);
--sidebar-ring: oklch(0.45 0.15 240);
}
[data-theme='sepia'] {
--background: oklch(0.96 0.02 85);
--foreground: oklch(0.25 0.02 85);
@@ -189,7 +255,8 @@
/* Latin languages use default (inherits from html) */
[lang='en'] body,
[lang='fr'] body {
font-size: 1rem; /* Uses html font size */
font-size: 1rem;
/* Uses html font size */
}
/* Persian/Farsi font with larger size for better readability */
@@ -214,7 +281,7 @@
}
/* Ensure all children of toaster don't block except the toast itself */
[data-sonner-toaster] > * {
[data-sonner-toaster]>* {
pointer-events: none !important;
}
@@ -228,3 +295,33 @@
[data-sonner-toaster]::after {
pointer-events: none !important;
}
/* ============================================
Muuri Grid Styles for Drag & Drop
============================================ */
.muuri-grid {
position: relative;
}
/* Note: Width is controlled by Tailwind classes (w-1/2, w-1/3, w-full, etc.) */
.muuri-item {
position: absolute;
/* width: 100%; REMOVED - Don't override Tailwind size classes */
}
.muuri-item.muuri-item-dragging {
z-index: 3;
}
.muuri-item.muuri-item-releasing {
z-index: 2;
}
.muuri-item.muuri-item-hidden {
z-index: 0;
}
/* Ensure note cards work properly with Muuri */
.muuri-item>* {
width: 100%;
}

View File

@@ -2,13 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toast";
import { LabelProvider } from "@/context/LabelContext";
import { NoteRefreshProvider } from "@/context/NoteRefreshContext";
import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
import { LanguageProvider } from "@/lib/i18n/LanguageProvider";
import { detectUserLanguage } from "@/lib/i18n/detect-user-language";
import { NotebooksProvider } from "@/context/notebooks-context";
import { NotebookDragProvider } from "@/context/notebook-drag-context";
const inter = Inter({
subsets: ["latin"],
@@ -35,29 +29,50 @@ export const viewport: Viewport = {
export const dynamic = "force-dynamic";
import { getAISettings } from "@/app/actions/ai-settings";
import { getUserSettings } from "@/app/actions/user-settings";
import { ThemeInitializer } from "@/components/theme-initializer";
// ... existing imports
import { DebugTheme } from "@/components/debug-theme";
// ...
import { getThemeScript } from "@/lib/theme-script";
// ...
import { auth } from "@/auth";
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Detect initial language for user
const initialLanguage = await detectUserLanguage()
const session = await auth();
const userId = session?.user?.id;
// Fetch user settings server-side with optimized single session check
const [aiSettings, userSettings] = await Promise.all([
getAISettings(userId),
getUserSettings(userId)
])
console.log('[RootLayout] Auth user:', userId)
console.log('[RootLayout] Server fetched user settings:', userSettings)
return (
<html lang={initialLanguage} suppressHydrationWarning>
<html suppressHydrationWarning>
<body className={inter.className}>
<script
dangerouslySetInnerHTML={{
__html: getThemeScript(userSettings.theme),
}}
/>
<SessionProviderWrapper>
<NoteRefreshProvider>
<LabelProvider>
<NotebooksProvider>
<NotebookDragProvider>
<LanguageProvider initialLanguage={initialLanguage}>
{children}
</LanguageProvider>
</NotebookDragProvider>
</NotebooksProvider>
</LabelProvider>
</NoteRefreshProvider>
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
{children}
<Toaster />
</SessionProviderWrapper>
</body>