feat: rename keep-notes to memento-note, migrate to PostgreSQL, fix MCP bugs

- Rename directory keep-notes -> memento-note with all code references
- Prisma: SQLite -> PostgreSQL (both app and MCP server schemas)
- Sync MCP schema with main app (add missing fields, relations, indexes)
- Delete 17 SQLite migrations (clean slate for PostgreSQL)
- Remove SQLite dependencies (@libsql/client, better-sqlite3, etc.)
- Fix MCP server: hardcoded Windows DB paths -> DATABASE_URL env var
- Fix MCP server: .dockerignore excluded index-sse.js (SSE mode broken)
- MCP Dockerfile: node:20 -> node:22
- Docker Compose: add postgres service, remove SQLite volume
- Generate favicon.ico, icon-192.png, icon-512.png, apple-icon.png
- Update layout.tsx icons and manifest.json for PNG icons
- Update all .env files for PostgreSQL
- Rewrite README.md with updated sections
- Remove mcp-server/node_modules and prisma/client-generated from git tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sepehr Ramezani
2026-04-20 20:58:04 +02:00
parent e2ddfa53d4
commit aa6a214f37
3548 changed files with 440 additions and 516800 deletions

View File

@@ -0,0 +1,79 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { forgotPassword } from '@/app/actions/auth-reset'
import { toast } from 'sonner'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
export default function ForgotPasswordPage() {
const { t } = useLanguage()
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDone, setIsSubmittingDone] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const result = await forgotPassword(formData.get('email') as string)
setIsSubmitting(false)
if (result.error) {
toast.error(result.error)
} else {
setIsSubmittingDone(true)
}
}
if (isDone) {
return (
<main className="flex items-center justify-center md:h-screen p-4">
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>{t('auth.checkYourEmail')}</CardTitle>
<CardDescription>
{t('auth.resetEmailSent')}
</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/login" className="w-full">
<Button variant="outline" className="w-full">{t('auth.returnToLogin')}</Button>
</Link>
</CardFooter>
</Card>
</main>
)
}
return (
<main className="flex items-center justify-center md:h-screen p-4">
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>{t('auth.forgotPasswordTitle')}</CardTitle>
<CardDescription>
{t('auth.forgotPasswordDescription')}
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">{t('auth.email')}</label>
<Input id="email" name="email" type="email" required placeholder="name@example.com" />
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? t('auth.sending') : t('auth.sendResetLink')}
</Button>
<Link href="/login" className="text-sm text-center underline">
{t('auth.backToLogin')}
</Link>
</CardFooter>
</form>
</Card>
</main>
)
}

View File

@@ -0,0 +1,19 @@
'use client';
import { LanguageProvider } from '@/lib/i18n/LanguageProvider';
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<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>
</LanguageProvider>
);
}

View File

@@ -0,0 +1,17 @@
import { LoginForm } from '@/components/login-form';
import { getSystemConfig } from '@/lib/config';
export default async function LoginPage() {
const config = await getSystemConfig();
// Default to true unless explicitly disabled in DB or Env
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<LoginForm allowRegister={allowRegister} />
</div>
</main>
);
}

View File

@@ -0,0 +1,20 @@
import { RegisterForm } from '@/components/register-form';
import { getSystemConfig } from '@/lib/config';
import { redirect } from 'next/navigation';
export default async function RegisterPage() {
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
redirect('/login');
}
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<RegisterForm />
</div>
</main>
);
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState, Suspense } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { resetPassword } from '@/app/actions/auth-reset'
import { toast } from 'sonner'
import { useSearchParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
function ResetPasswordForm() {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const [isSubmitting, setIsSubmitting] = useState(false)
const token = searchParams.get('token')
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!token) return
const formData = new FormData(e.currentTarget)
const password = formData.get('password') as string
const confirm = formData.get('confirmPassword') as string
if (password !== confirm) {
toast.error(t('resetPassword.passwordMismatch'))
return
}
setIsSubmitting(true)
const result = await resetPassword(token, password)
setIsSubmitting(false)
if (result.error) {
toast.error(result.error)
} else {
toast.success(t('resetPassword.success'))
router.push('/login')
}
}
if (!token) {
return (
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>{t('resetPassword.invalidLinkTitle')}</CardTitle>
<CardDescription>{t('resetPassword.invalidLinkDescription')}</CardDescription>
</CardHeader>
<CardFooter>
<Link href="/forgot-password" title="Try again" className="w-full">
<Button variant="outline" className="w-full">{t('resetPassword.requestNewLink')}</Button>
</Link>
</CardFooter>
</Card>
)
}
return (
<Card className="w-full max-w-[400px]">
<CardHeader>
<CardTitle>{t('resetPassword.title')}</CardTitle>
<CardDescription>{t('resetPassword.description')}</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="password">{t('resetPassword.newPassword')}</label>
<Input id="password" name="password" type="password" required minLength={6} autoFocus />
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword">{t('resetPassword.confirmNewPassword')}</label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? t('resetPassword.resetting') : t('resetPassword.resetPassword')}
</Button>
</CardFooter>
</form>
</Card>
)
}
export default function ResetPasswordPage() {
const { t } = useLanguage()
return (
<main className="flex items-center justify-center md:h-screen p-4">
<Suspense fallback={<div>{t('resetPassword.loading')}</div>}>
<ResetPasswordForm />
</Suspense>
</main>
)
}

View File

@@ -0,0 +1,259 @@
'use client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import { Loader2, CheckCircle2, XCircle, Clock, Zap, Info } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface TestResult {
success: boolean
provider?: string
model?: string
responseTime?: number
tags?: Array<{ tag: string; confidence: number }>
embeddingLength?: number
firstValues?: number[]
error?: string
details?: any
}
export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
const { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const [result, setResult] = useState<TestResult | null>(null)
const [config, setConfig] = useState<any>(null)
useEffect(() => {
fetchConfig()
}, [])
const fetchConfig = async () => {
try {
const response = await fetch('/api/ai/config')
const data = await response.json()
setConfig(data)
if (data.previousTest) {
setResult(data.previousTest[type] || null)
}
} catch (error) {
console.error('Failed to fetch config:', error)
}
}
const runTest = async () => {
setIsLoading(true)
const startTime = Date.now()
try {
const response = await fetch(`/api/ai/test-${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
const endTime = Date.now()
const data = await response.json()
setResult({
...data,
responseTime: endTime - startTime
})
if (data.success) {
toast.success(
`${type === 'tags' ? 'Tags' : 'Embeddings'} Test Successful!`,
{
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
}
)
} else {
toast.error(
`${type === 'tags' ? 'Tags' : 'Embeddings'} Test Failed`,
{
description: data.error || 'Unknown error'
}
)
}
} catch (error: any) {
const endTime = Date.now()
const errorResult = {
success: false,
error: error.message || 'Network error',
responseTime: endTime - startTime
}
setResult(errorResult)
toast.error(t('admin.aiTest.testError', { error: error.message }))
} finally {
setIsLoading(false)
}
}
const getProviderInfo = () => {
if (!config) return { provider: 'Loading...', model: 'Loading...' }
if (type === 'tags') {
return {
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest'
}
} else {
return {
provider: config.AI_PROVIDER_EMBEDDING || 'ollama',
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
}
}
}
const providerInfo = getProviderInfo()
return (
<div className="space-y-4">
{/* Provider Info */}
<div className="space-y-3 p-4 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('admin.aiTest.provider')}</span>
<Badge variant="outline" className="text-xs">
{providerInfo.provider.toUpperCase()}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t('admin.aiTest.model')}</span>
<span className="text-sm text-muted-foreground font-mono">
{providerInfo.model}
</span>
</div>
</div>
{/* Test Button */}
<Button
onClick={runTest}
disabled={isLoading}
className="w-full"
variant={result?.success ? 'default' : result?.success === false ? 'destructive' : 'default'}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('admin.aiTest.testing')}
</>
) : (
<>
<Zap className="mr-2 h-4 w-4" />
{t('admin.aiTest.runTest')}
</>
)}
</Button>
{/* Results */}
{result && (
<Card className={result.success ? 'border-green-200 dark:border-green-900' : 'border-red-200 dark:border-red-900'}>
<CardContent className="pt-6">
{/* Status */}
<div className="flex items-center gap-2 mb-4">
{result.success ? (
<>
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-semibold text-green-600 dark:text-green-400">{t('admin.aiTest.testPassed')}</span>
</>
) : (
<>
<XCircle className="h-5 w-5 text-red-600" />
<span className="font-semibold text-red-600 dark:text-red-400">{t('admin.aiTest.testFailed')}</span>
</>
)}
</div>
{/* Response Time */}
{result.responseTime && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-4">
<Clock className="h-4 w-4" />
<span>{t('admin.aiTest.responseTime', { time: result.responseTime })}</span>
</div>
)}
{/* Tags Results */}
{type === 'tags' && result.success && result.tags && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{t('admin.aiTest.generatedTags')}</span>
</div>
<div className="flex flex-wrap gap-2">
{result.tags.map((tag, idx) => (
<Badge
key={idx}
variant="secondary"
className="text-sm"
>
{tag.tag}
<span className="ml-1 text-xs opacity-70">
({Math.round(tag.confidence * 100)}%)
</span>
</Badge>
))}
</div>
</div>
)}
{/* Embeddings Results */}
{type === 'embeddings' && result.success && result.embeddingLength && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">{t('admin.aiTest.embeddingDimensions')}</span>
</div>
<div className="p-3 bg-muted rounded-lg">
<div className="text-2xl font-bold text-center">
{result.embeddingLength}
</div>
<div className="text-xs text-center text-muted-foreground mt-1">
{t('admin.aiTest.vectorDimensions')}
</div>
</div>
{result.firstValues && result.firstValues.length > 0 && (
<div className="space-y-1">
<span className="text-xs font-medium">{t('admin.aiTest.first5Values')}</span>
<div className="p-2 bg-muted rounded font-mono text-xs">
[{result.firstValues.slice(0, 5).map((v, i) => v.toFixed(4)).join(', ')}]
</div>
</div>
)}
</div>
)}
{/* Error Details */}
{!result.success && result.error && (
<div className="mt-4 p-3 bg-red-50 dark:bg-red-950/20 rounded-lg border border-red-200 dark:border-red-900">
<p className="text-sm font-medium text-red-900 dark:text-red-100">{t('admin.aiTest.error')}</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{result.error}</p>
{result.details && (
<details className="mt-2">
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
Technical details
</summary>
<pre className="mt-2 text-xs overflow-auto p-2 bg-red-100 dark:bg-red-900/30 rounded">
{JSON.stringify(result.details, null, 2)}
</pre>
</details>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Loading State */}
{isLoading && (
<div className="text-center py-4">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="text-sm text-muted-foreground mt-2">
{t('admin.aiTest.testing')} {type === 'tags' ? 'tags generation' : 'embeddings'}...
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, TestTube } from 'lucide-react'
import { AI_TESTER } from './ai-tester'
export default async function AITestPage() {
const session = await auth()
if ((session?.user as any)?.role !== 'ADMIN') {
redirect('/')
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-3">
<Link href="/admin/settings">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<TestTube className="h-8 w-8" />
AI Provider Testing
</h1>
<p className="text-muted-foreground mt-1">
Test your AI providers for tag generation and semantic search embeddings
</p>
</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Tags Provider Test */}
<Card className="border-primary/20 dark:border-primary/30">
<CardHeader className="bg-primary/5 dark:bg-primary/10">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🏷</span>
Tags Generation Test
</CardTitle>
<CardDescription>
Test the AI provider responsible for automatic tag suggestions
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<AI_TESTER type="tags" />
</CardContent>
</Card>
{/* Embeddings Provider Test */}
<Card className="border-green-200 dark:border-green-900">
<CardHeader className="bg-green-50/50 dark:bg-green-950/20">
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">🔍</span>
Embeddings Test
</CardTitle>
<CardDescription>
Test the AI provider responsible for semantic search embeddings
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<AI_TESTER type="embeddings" />
</CardContent>
</Card>
</div>
{/* Info Section */}
<Card className="mt-6">
<CardHeader>
<CardTitle> How Testing Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<h4 className="font-semibold mb-2">🏷 Tags Generation Test:</h4>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Sends a sample note to the AI provider</li>
<li>Requests 3-5 relevant tags based on the content</li>
<li>Displays the generated tags with confidence scores</li>
<li>Measures response time</li>
</ul>
</div>
<div>
<h4 className="font-semibold mb-2">🔍 Embeddings Test:</h4>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Sends a sample text to the embedding provider</li>
<li>Generates a vector representation (list of numbers)</li>
<li>Displays embedding dimensions and sample values</li>
<li>Verifies the vector is valid and properly formatted</li>
</ul>
</div>
<div className="bg-amber-50 dark:bg-amber-950/20 p-4 rounded-lg border border-amber-200 dark:border-amber-900">
<p className="font-semibold text-amber-900 dark:text-amber-100">💡 Tip:</p>
<p className="text-amber-800 dark:text-amber-200 mt-1">
You can use different providers for tags and embeddings! For example, use Ollama (free) for tags
and OpenAI (best quality) for embeddings to optimize costs and performance.
</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,147 @@
import { AdminMetrics } from '@/components/admin-metrics'
import { Button } from '@/components/ui/button'
import { Zap, Settings, Activity, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import { getSystemConfig } from '@/lib/config'
export default async function AdminAIPage() {
const config = await getSystemConfig()
// Determine provider status based on config
const openaiKey = config.OPENAI_API_KEY
const ollamaUrl = config.OLLAMA_BASE_URL || config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL_EMBEDDING
const providers = [
{
name: 'OpenAI',
status: openaiKey ? 'Connected' : 'Not Configured',
requests: 'N/A' // We don't track request counts yet
},
{
name: 'Ollama',
status: ollamaUrl ? 'Available' : 'Not Configured',
requests: 'N/A'
},
]
// Mock AI metrics - in a real app, these would come from analytics
// TODO: Implement real analytics tracking
const aiMetrics = [
{
title: 'Total Requests',
value: '—',
trend: { value: 0, isPositive: true },
icon: <Zap className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />,
},
{
title: 'Success Rate',
value: '100%',
trend: { value: 0, isPositive: true },
icon: <TrendingUp className="h-5 w-5 text-green-600 dark:text-green-400" />,
},
{
title: 'Avg Response Time',
value: '—',
trend: { value: 0, isPositive: true },
icon: <Activity className="h-5 w-5 text-primary dark:text-primary-foreground" />,
},
{
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>
<Link href="/admin/settings">
<Button variant="outline">
<Settings className="mr-2 h-4 w-4" />
Configure
</Button>
</Link>
</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">
{providers.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' || provider.status === 'Available'
? 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'
}`}
>
{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. (Coming Soon)
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Plus } from 'lucide-react'
import { createUser } from '@/app/actions/admin'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export function CreateUserDialog() {
const [open, setOpen] = useState(false)
const { t } = useLanguage()
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" /> {t('admin.users.addUser')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('admin.users.createUser')}</DialogTitle>
<DialogDescription>
{t('admin.users.createUserDescription')}
</DialogDescription>
</DialogHeader>
<form
action={async (formData) => {
const result = await createUser(formData)
if (result?.error) {
toast.error(t('admin.users.createFailed'))
} else {
toast.success(t('admin.users.createSuccess'))
setOpen(false)
}
}}
className="grid gap-4 py-4"
>
<div className="grid gap-2">
<label htmlFor="name">{t('admin.users.name')}</label>
<Input id="name" name="name" required />
</div>
<div className="grid gap-2">
<label htmlFor="email">{t('admin.users.email')}</label>
<Input id="email" name="email" type="email" required />
</div>
<div className="grid gap-2">
<label htmlFor="password">{t('admin.users.password')}</label>
<Input id="password" name="password" type="password" required minLength={6} />
</div>
<div className="grid gap-2">
<label htmlFor="role">{t('admin.users.role')}</label>
<select
id="role"
name="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="USER">User</option>
<option value="ADMIN">Admin</option>
</select>
</div>
<DialogFooter>
<Button type="submit">{t('admin.users.createUser')}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

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

@@ -0,0 +1,59 @@
import { getUsers } from '@/app/actions/admin'
import { AdminMetrics } from '@/components/admin-metrics'
import { Users, Activity, Database, Zap } from 'lucide-react'
export default async function AdminPage() {
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-primary dark:text-primary-foreground" />,
},
{
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="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>
<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

@@ -0,0 +1,618 @@
'use client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
import { getOllamaModels } from '@/app/actions/ollama'
import { toast } from 'sonner'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { TestTube, ExternalLink, RefreshCw } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
type AIProvider = 'ollama' | 'openai' | 'custom'
interface AvailableModels {
tags: string[]
embeddings: string[]
}
const MODELS_2026 = {
// Removed hardcoded Ollama models in favor of dynamic fetching
openai: {
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
},
custom: {
tags: ['gpt-4o-mini', 'gpt-4o', 'claude-3-haiku', 'claude-3-sonnet', 'llama-3.1-8b'],
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
}
}
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
const { t } = useLanguage()
const [isSaving, setIsSaving] = useState(false)
const [isTesting, setIsTesting] = useState(false)
// Local state for Checkbox
const [allowRegister, setAllowRegister] = useState(config.ALLOW_REGISTRATION !== 'false')
const [smtpSecure, setSmtpSecure] = useState(config.SMTP_SECURE === 'true')
const [smtpIgnoreCert, setSmtpIgnoreCert] = useState(config.SMTP_IGNORE_CERT === 'true')
// AI Provider state - separated for tags and embeddings
const [tagsProvider, setTagsProvider] = useState<AIProvider>((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
const [embeddingsProvider, setEmbeddingsProvider] = useState<AIProvider>((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
// Selected Models State (Controlled Inputs)
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
// Dynamic Models State
const [ollamaTagsModels, setOllamaTagsModels] = useState<string[]>([])
const [ollamaEmbeddingsModels, setOllamaEmbeddingsModels] = useState<string[]>([])
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
// Sync state with config
useEffect(() => {
setAllowRegister(config.ALLOW_REGISTRATION !== 'false')
setSmtpSecure(config.SMTP_SECURE === 'true')
setSmtpIgnoreCert(config.SMTP_IGNORE_CERT === 'true')
setTagsProvider((config.AI_PROVIDER_TAGS as AIProvider) || 'ollama')
setEmbeddingsProvider((config.AI_PROVIDER_EMBEDDING as AIProvider) || 'ollama')
setSelectedTagsModel(config.AI_MODEL_TAGS || '')
setSelectedEmbeddingModel(config.AI_MODEL_EMBEDDING || '')
}, [config])
// Fetch Ollama models
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
if (!url) return
if (type === 'tags') setIsLoadingTagsModels(true)
else setIsLoadingEmbeddingsModels(true)
try {
const result = await getOllamaModels(url)
if (result.success) {
if (type === 'tags') setOllamaTagsModels(result.models)
else setOllamaEmbeddingsModels(result.models)
} else {
toast.error(`Failed to fetch Ollama models: ${result.error}`)
}
} catch (error) {
console.error(error)
toast.error('Failed to fetch Ollama models')
} finally {
if (type === 'tags') setIsLoadingTagsModels(false)
else setIsLoadingEmbeddingsModels(false)
}
}, [])
// Initial fetch for Ollama models if provider is selected
useEffect(() => {
if (tagsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('tags', url)
}
}, [tagsProvider, config.OLLAMA_BASE_URL_TAGS, config.OLLAMA_BASE_URL, fetchOllamaModels])
useEffect(() => {
if (embeddingsProvider === 'ollama') {
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
}
}, [embeddingsProvider, config.OLLAMA_BASE_URL_EMBEDDING, config.OLLAMA_BASE_URL, fetchOllamaModels])
const handleSaveSecurity = async (formData: FormData) => {
setIsSaving(true)
const data = {
ALLOW_REGISTRATION: allowRegister ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.security.updateFailed'))
} else {
toast.success(t('admin.security.updateSuccess'))
}
}
const handleSaveAI = async (formData: FormData) => {
setIsSaving(true)
const data: Record<string, string> = {}
try {
const tagsProv = formData.get('AI_PROVIDER_TAGS') as AIProvider
if (!tagsProv) throw new Error(t('admin.ai.providerTagsRequired'))
data.AI_PROVIDER_TAGS = tagsProv
const tagsModel = formData.get('AI_MODEL_TAGS') as string
if (tagsModel) data.AI_MODEL_TAGS = tagsModel
if (tagsProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_TAGS') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_TAGS = ollamaUrl
} else if (tagsProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
} else if (tagsProv === 'custom') {
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_TAGS') as string
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_TAGS') as string
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
const embedProv = formData.get('AI_PROVIDER_EMBEDDING') as AIProvider
if (!embedProv) throw new Error(t('admin.ai.providerEmbeddingRequired'))
data.AI_PROVIDER_EMBEDDING = embedProv
const embedModel = formData.get('AI_MODEL_EMBEDDING') as string
if (embedModel) data.AI_MODEL_EMBEDDING = embedModel
if (embedProv === 'ollama') {
const ollamaUrl = formData.get('OLLAMA_BASE_URL_EMBEDDING') as string
if (ollamaUrl) data.OLLAMA_BASE_URL_EMBEDDING = ollamaUrl
} else if (embedProv === 'openai') {
const openaiKey = formData.get('OPENAI_API_KEY') as string
if (openaiKey) data.OPENAI_API_KEY = openaiKey
} else if (embedProv === 'custom') {
const customKey = formData.get('CUSTOM_OPENAI_API_KEY_EMBEDDING') as string
const customUrl = formData.get('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as string
if (customKey) data.CUSTOM_OPENAI_API_KEY = customKey
if (customUrl) data.CUSTOM_OPENAI_BASE_URL = customUrl
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.ai.updateFailed') + ': ' + result.error)
} else {
toast.success(t('admin.ai.updateSuccess'))
setTagsProvider(tagsProv)
setEmbeddingsProvider(embedProv)
// Refresh models after save if Ollama is selected
if (tagsProv === 'ollama') {
const url = data.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('tags', url)
}
if (embedProv === 'ollama') {
const url = data.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
fetchOllamaModels('embeddings', url)
}
}
} catch (error: any) {
setIsSaving(false)
toast.error(t('general.error') + ': ' + error.message)
}
}
const handleSaveSMTP = async (formData: FormData) => {
setIsSaving(true)
const data = {
SMTP_HOST: formData.get('SMTP_HOST') as string,
SMTP_PORT: formData.get('SMTP_PORT') as string,
SMTP_USER: formData.get('SMTP_USER') as string,
SMTP_PASS: formData.get('SMTP_PASS') as string,
SMTP_FROM: formData.get('SMTP_FROM') as string,
SMTP_IGNORE_CERT: smtpIgnoreCert ? 'true' : 'false',
SMTP_SECURE: smtpSecure ? 'true' : 'false',
}
const result = await updateSystemConfig(data)
setIsSaving(false)
if (result.error) {
toast.error(t('admin.smtp.updateFailed'))
} else {
toast.success(t('admin.smtp.updateSuccess'))
}
}
const handleTestEmail = async () => {
setIsTesting(true)
try {
const result: any = await testSMTP()
if (result.success) {
toast.success(t('admin.smtp.testSuccess'))
} else {
toast.error(t('admin.smtp.testFailed', { error: result.error }))
}
} catch (e: any) {
toast.error(t('general.error') + ': ' + e.message)
} finally {
setIsTesting(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('admin.security.title')}</CardTitle>
<CardDescription>{t('admin.security.description')}</CardDescription>
</CardHeader>
<form action={handleSaveSecurity}>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="ALLOW_REGISTRATION"
checked={allowRegister}
onCheckedChange={(c) => setAllowRegister(!!c)}
/>
<label
htmlFor="ALLOW_REGISTRATION"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('admin.security.allowPublicRegistration')}
</label>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.security.allowPublicRegistrationDescription')}
</p>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSaving}>{t('admin.security.title')}</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.ai.title')}</CardTitle>
<CardDescription>{t('admin.ai.description')}</CardDescription>
</CardHeader>
<form action={handleSaveAI}>
<CardContent className="space-y-6">
<div className="space-y-4 p-4 border rounded-lg bg-primary/5 dark:bg-primary/10">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-primary">🏷</span> {t('admin.ai.tagsGenerationProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.tagsGenerationDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_TAGS">{t('admin.ai.provider')}</Label>
<select
id="AI_PROVIDER_TAGS"
name="AI_PROVIDER_TAGS"
value={tagsProvider}
onChange={(e) => setTagsProvider(e.target.value as AIProvider)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
</div>
{tagsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_TAGS"
name="OLLAMA_BASE_URL_TAGS"
defaultValue={config.OLLAMA_BASE_URL_TAGS || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_TAGS') as HTMLInputElement
fetchOllamaModels('tags', input.value)
}}
disabled={isLoadingTagsModels}
title="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingTagsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OLLAMA"
name="AI_MODEL_TAGS_OLLAMA"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{ollamaTagsModels.length > 0 ? (
ollamaTagsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedTagsModel || 'granite4:latest'}>{selectedTagsModel || 'granite4:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">
{isLoadingTagsModels ? 'Fetching models...' : t('admin.ai.selectOllamaModel')}
</p>
</div>
</div>
)}
{tagsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_OPENAI"
name="AI_MODEL_TAGS_OPENAI"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">gpt-4o-mini</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">gpt-4o</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{tagsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_TAGS" name="CUSTOM_OPENAI_API_KEY_TAGS" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_TAGS_CUSTOM"
name="AI_MODEL_TAGS_CUSTOM"
value={selectedTagsModel}
onChange={(e) => setSelectedTagsModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.custom.tags.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
</div>
</div>
)}
</div>
<div className="space-y-4 p-4 border rounded-lg bg-green-50/50 dark:bg-green-950/20">
<h3 className="text-base font-semibold flex items-center gap-2">
<span className="text-green-600">🔍</span> {t('admin.ai.embeddingsProvider')}
</h3>
<p className="text-xs text-muted-foreground">{t('admin.ai.embeddingsDescription')}</p>
<div className="space-y-2">
<Label htmlFor="AI_PROVIDER_EMBEDDING">
{t('admin.ai.provider')}
<span className="ml-2 text-xs text-muted-foreground">
(Current: {embeddingsProvider})
</span>
</Label>
<select
id="AI_PROVIDER_EMBEDDING"
name="AI_PROVIDER_EMBEDDING"
value={embeddingsProvider}
onChange={(e) => setEmbeddingsProvider(e.target.value as AIProvider)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="ollama">{t('admin.ai.providerOllamaOption')}</option>
<option value="openai">{t('admin.ai.providerOpenAIOption')}</option>
<option value="custom">{t('admin.ai.providerCustomOption')}</option>
</select>
<p className="text-xs text-muted-foreground">
Config value: {config.AI_PROVIDER_EMBEDDING || 'Not set (defaults to ollama)'}
</p>
</div>
{embeddingsProvider === 'ollama' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OLLAMA_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<div className="flex gap-2">
<Input
id="OLLAMA_BASE_URL_EMBEDDING"
name="OLLAMA_BASE_URL_EMBEDDING"
defaultValue={config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'}
placeholder="http://localhost:11434"
/>
<Button
type="button"
size="icon"
variant="outline"
onClick={() => {
const input = document.getElementById('OLLAMA_BASE_URL_EMBEDDING') as HTMLInputElement
fetchOllamaModels('embeddings', input.value)
}}
disabled={isLoadingEmbeddingsModels}
title="Refresh Models"
>
<RefreshCw className={`h-4 w-4 ${isLoadingEmbeddingsModels ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OLLAMA">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OLLAMA"
name="AI_MODEL_EMBEDDING_OLLAMA"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{ollamaEmbeddingsModels.length > 0 ? (
ollamaEmbeddingsModels.map((model) => (
<option key={model} value={model}>{model}</option>
))
) : (
<option value={selectedEmbeddingModel || 'embeddinggemma:latest'}>{selectedEmbeddingModel || 'embeddinggemma:latest'} {t('admin.ai.saved')}</option>
)}
</select>
<p className="text-xs text-muted-foreground">
{isLoadingEmbeddingsModels ? 'Fetching models...' : t('admin.ai.selectEmbeddingModel')}
</p>
</div>
</div>
)}
{embeddingsProvider === 'openai' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="OPENAI_API_KEY">{t('admin.ai.apiKey')}</Label>
<Input id="OPENAI_API_KEY" name="OPENAI_API_KEY" type="password" defaultValue={config.OPENAI_API_KEY || ''} placeholder="sk-..." />
<p className="text-xs text-muted-foreground">{t('admin.ai.openAIKeyDescription')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_OPENAI">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_OPENAI"
name="AI_MODEL_EMBEDDING_OPENAI"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.openai.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground"><strong className="text-green-600">text-embedding-3-small</strong> = {t('admin.ai.bestValue')} <strong className="text-primary">text-embedding-3-large</strong> = {t('admin.ai.bestQuality')}</p>
</div>
</div>
)}
{embeddingsProvider === 'custom' && (
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
</div>
<div className="space-y-2">
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
<Input id="CUSTOM_OPENAI_API_KEY_EMBEDDING" name="CUSTOM_OPENAI_API_KEY_EMBEDDING" type="password" defaultValue={config.CUSTOM_OPENAI_API_KEY || ''} placeholder="sk-..." />
</div>
<div className="space-y-2">
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
<select
id="AI_MODEL_EMBEDDING_CUSTOM"
name="AI_MODEL_EMBEDDING_CUSTOM"
value={selectedEmbeddingModel}
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{MODELS_2026.custom.embeddings.map((model) => (
<option key={model} value={model}>{model}</option>
))}
</select>
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
</div>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
<Link href="/admin/ai-test">
<Button type="button" variant="outline" className="gap-2">
<TestTube className="h-4 w-4" />
{t('admin.ai.openTestPanel')}
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('admin.smtp.title')}</CardTitle>
<CardDescription>{t('admin.smtp.description')}</CardDescription>
</CardHeader>
<form action={handleSaveSMTP}>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="SMTP_HOST" className="text-sm font-medium">{t('admin.smtp.host')}</label>
<Input id="SMTP_HOST" name="SMTP_HOST" defaultValue={config.SMTP_HOST || ''} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PORT" className="text-sm font-medium">{t('admin.smtp.port')}</label>
<Input id="SMTP_PORT" name="SMTP_PORT" defaultValue={config.SMTP_PORT || '587'} placeholder="587" />
</div>
</div>
<div className="space-y-2">
<label htmlFor="SMTP_USER" className="text-sm font-medium">{t('admin.smtp.username')}</label>
<Input id="SMTP_USER" name="SMTP_USER" defaultValue={config.SMTP_USER || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_PASS" className="text-sm font-medium">{t('admin.smtp.password')}</label>
<Input id="SMTP_PASS" name="SMTP_PASS" type="password" defaultValue={config.SMTP_PASS || ''} />
</div>
<div className="space-y-2">
<label htmlFor="SMTP_FROM" className="text-sm font-medium">{t('admin.smtp.fromEmail')}</label>
<Input id="SMTP_FROM" name="SMTP_FROM" defaultValue={config.SMTP_FROM || 'noreply@memento.app'} />
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_SECURE"
checked={smtpSecure}
onCheckedChange={(c) => setSmtpSecure(!!c)}
/>
<label
htmlFor="SMTP_SECURE"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('admin.smtp.forceSSL')}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="SMTP_IGNORE_CERT"
checked={smtpIgnoreCert}
onCheckedChange={(c) => setSmtpIgnoreCert(!!c)}
/>
<label
htmlFor="SMTP_IGNORE_CERT"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-yellow-600"
>
{t('admin.smtp.ignoreCertErrors')}
</label>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { getSystemConfig } from '@/app/actions/admin-settings'
import { AdminSettingsForm } from './admin-settings-form'
export default async function AdminSettingsPage() {
const config = await getSystemConfig()
return (
<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,83 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { deleteUser, updateUserRole } from '@/app/actions/admin'
import { toast } from 'sonner'
import { Trash2, Shield, ShieldOff } from 'lucide-react'
import { format } from 'date-fns'
import { useLanguage } from '@/lib/i18n'
export function UserList({ initialUsers }: { initialUsers: any[] }) {
const { t } = useLanguage()
const handleDelete = async (id: string) => {
if (!confirm(t('admin.users.confirmDelete'))) return
try {
await deleteUser(id)
toast.success(t('admin.users.deleteSuccess'))
} catch (e) {
toast.error(t('admin.users.deleteFailed'))
}
}
const handleRoleToggle = async (user: any) => {
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
try {
await updateUserRole(user.id, newRole)
toast.success(t('admin.users.roleUpdateSuccess', { role: newRole }))
} catch (e) {
toast.error(t('admin.users.roleUpdateFailed'))
}
}
return (
<div className="w-full overflow-auto">
<table className="w-full caption-bottom text-sm text-left">
<thead className="[&_tr]:border-b">
<tr className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.name')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.email')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.role')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground">{t('admin.users.table.createdAt')}</th>
<th className="h-12 px-4 align-middle font-medium text-muted-foreground text-right">{t('admin.users.table.actions')}</th>
</tr>
</thead>
<tbody className="[&_tr:last-child]:border-0">
{initialUsers.map((user) => (
<tr key={user.id} className="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td className="p-4 align-middle font-medium">{user.name || t('common.notAvailable')}</td>
<td className="p-4 align-middle">{user.email}</td>
<td className="p-4 align-middle">
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${user.role === 'ADMIN' ? 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80' : 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80'}`}>
{user.role === 'ADMIN' ? t('admin.users.roles.admin') : t('admin.users.roles.user')}
</span>
</td>
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
<td className="p-4 align-middle text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleRoleToggle(user)}
title={user.role === 'ADMIN' ? t('admin.users.demote') : t('admin.users.promote')}
>
{user.role === 'ADMIN' ? <ShieldOff className="h-4 w-4" /> : <Shield className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</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

@@ -0,0 +1,16 @@
import { getArchivedNotes } from '@/app/actions/notes'
import { MasonryGrid } from '@/components/masonry-grid'
import { ArchiveHeader } from '@/components/archive-header'
export const dynamic = 'force-dynamic'
export default async function ArchivePage() {
const notes = await getArchivedNotes()
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<ArchiveHeader />
<MasonryGrid notes={notes} />
</main>
)
}

View File

@@ -0,0 +1,34 @@
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,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
const initialLanguage = await detectUserLanguage();
return (
<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>
</ProvidersWrapper>
);
}

View File

@@ -0,0 +1,464 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Note } from '@/lib/types'
import { getAllNotes, getPinnedNotes, getRecentNotes, searchNotes } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { NoteInput } from '@/components/note-input'
import { MasonryGrid } from '@/components/masonry-grid'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { NoteEditor } from '@/components/note-editor'
import { BatchOrganizationDialog } from '@/components/batch-organization-dialog'
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
import { FavoritesSection } from '@/components/favorites-section'
import { RecentNotesSection } from '@/components/recent-notes-section'
import { Button } from '@/components/ui/button'
import { Wand2 } from 'lucide-react'
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'
import { useLanguage } from '@/lib/i18n'
export default function HomePage() {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
// Force re-render when search params change (for filtering)
const [notes, setNotes] = useState<Note[]>([])
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
const [recentNotes, setRecentNotes] = useState<Note[]>([])
const [showRecentNotes, setShowRecentNotes] = useState(false)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const { refreshKey } = useNoteRefresh()
const { labels } = useLabels()
// Auto label suggestion (IA4)
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
// Open auto label dialog when suggestion is available
useEffect(() => {
if (shouldSuggestLabels && suggestNotebookId) {
setAutoLabelOpen(true)
}
}, [shouldSuggestLabels, suggestNotebookId])
// Check if viewing Notes générales (no notebook filter)
const notebookFilter = searchParams.get('notebook')
const isInbox = !notebookFilter
// Callback for NoteInput to trigger notebook suggestion and update UI
const handleNoteCreated = useCallback((note: Note) => {
// Update UI immediately by adding the note to the list if it matches current filters
setNotes((prevNotes) => {
// Check if note matches current filters
const notebookFilter = searchParams.get('notebook')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const search = searchParams.get('search')?.trim() || null
// Check notebook filter
if (notebookFilter && note.notebookId !== notebookFilter) {
return prevNotes // Note doesn't match notebook filter
}
if (!notebookFilter && note.notebookId) {
return prevNotes // Viewing inbox but note has notebook
}
// Check label filter
if (labelFilter.length > 0) {
const noteLabels = note.labels || []
if (!noteLabels.some((label: string) => labelFilter.includes(label))) {
return prevNotes // Note doesn't match label filter
}
}
// Check color filter
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
const noteLabels = note.labels || []
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) {
return prevNotes // Note doesn't match color filter
}
}
// Check search filter (simple check - if searching, let refresh handle it)
if (search) {
// If searching, refresh to get proper search results
router.refresh()
return prevNotes
}
// Note matches all filters - add it optimistically to the beginning of the list
// (newest notes first based on order: isPinned desc, order asc, updatedAt desc)
const isPinned = note.isPinned || false
const pinnedNotes = prevNotes.filter(n => n.isPinned)
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
if (isPinned) {
// Add to beginning of pinned notes
return [note, ...pinnedNotes, ...unpinnedNotes]
} else {
// Add to beginning of unpinned notes
return [...pinnedNotes, note, ...unpinnedNotes]
}
})
// Only suggest if note has no notebook and has 20+ words
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
if (wordCount >= 20) {
setNotebookSuggestion({
noteId: note.id,
content: note.content || ''
})
} else {
}
} else {
}
// Refresh in background to ensure data consistency (non-blocking)
router.refresh()
}, [searchParams, labels, router])
const handleOpenNote = (noteId: string) => {
const note = notes.find(n => n.id === noteId)
if (note) {
setEditingNote({ note, readOnly: false })
}
}
// Enable reminder notifications
useReminderCheck(notes)
// Load user settings to check if recent notes should be shown
useEffect(() => {
const loadSettings = async () => {
try {
const settings = await getAISettings()
setShowRecentNotes(settings.showRecentNotes === true)
} catch (error) {
setShowRecentNotes(false)
}
}
loadSettings()
}, [refreshKey])
useEffect(() => {
const loadNotes = async () => {
setIsLoading(true)
const search = searchParams.get('search')?.trim() || null
const semanticMode = searchParams.get('semantic') === 'true'
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const notebookFilter = searchParams.get('notebook')
let allNotes = search ? await searchNotes(search, semanticMode, notebookFilter || undefined) : await getAllNotes()
// Filter by selected notebook
if (notebookFilter) {
allNotes = allNotes.filter((note: any) => note.notebookId === notebookFilter)
} else {
// If no notebook selected, only show notes without notebook (Notes générales)
allNotes = allNotes.filter((note: any) => !note.notebookId)
}
// Filter by selected labels
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelFilter.includes(label))
)
}
// Filter by color (filter notes that have labels with this color)
// Note: We use a ref-like pattern to avoid including labels in dependencies
// This prevents dialog closing when adding new labels
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
)
}
// Load pinned notes separately (shown in favorites section)
const pinned = await getPinnedNotes(notebookFilter || undefined)
setPinnedNotes(pinned)
// Load recent notes only if enabled in settings
if (showRecentNotes) {
const recent = await getRecentNotes(3)
// Filter recent notes by current filters
if (notebookFilter) {
setRecentNotes(recent.filter((note: any) => note.notebookId === notebookFilter))
} else {
setRecentNotes(recent.filter((note: any) => !note.notebookId))
}
} else {
setRecentNotes([])
}
setNotes(allNotes)
setIsLoading(false)
}
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
// 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)
// 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-muted-foreground mb-1">
<span>{t('nav.notebooks')}</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-primary">{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-primary/10 dark:bg-primary/20 rounded-xl">
{(() => {
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
return (
<Icon
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
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-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
>
<Plus className="w-5 h-5" />
Add Note
</Button>
</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-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</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 >= 2 && (
<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={t('batch.organizeWithAI')}
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">{t('batch.organize')}</span>
</Button>
)}
<Button
onClick={() => setShowNoteInput(!showNoteInput)}
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
>
<Plus className="w-5 h-5" />
{t('notes.newNote')}
</Button>
</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>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
{/* Favorites Section - Pinned Notes */}
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
{/* Recent Notes Section - Only shown if enabled in settings */}
{showRecentNotes && (
<RecentNotesSection
recentNotes={recentNotes.filter(note => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
)}
{/* Main Notes Grid - Unpinned Notes Only */}
{notes.filter(note => !note.isPinned).length > 0 && (
<div data-testid="notes-grid">
<MasonryGrid
notes={notes.filter(note => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
</div>
)}
{/* Empty state when no notes */}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</div>
)}
</>
)}
{/* Memory Echo - Proactive note connections */}
<MemoryEchoNotification onOpenNote={handleOpenNote} />
{/* Notebook Suggestion - IA1 */}
{notebookSuggestion && (
<NotebookSuggestionToast
noteId={notebookSuggestion.noteId}
noteContent={notebookSuggestion.content}
onDismiss={() => setNotebookSuggestion(null)}
/>
)}
{/* Batch Organization Dialog - IA3 */}
<BatchOrganizationDialog
open={batchOrganizationOpen}
onOpenChange={setBatchOrganizationOpen}
onNotesMoved={() => {
// Refresh notes to see updated notebook assignments
router.refresh()
}}
/>
{/* Auto Label Suggestion Dialog - IA4 */}
<AutoLabelSuggestionDialog
open={autoLabelOpen}
onOpenChange={(open) => {
setAutoLabelOpen(open)
if (!open) dismissLabelSuggestion()
}}
notebookId={suggestNotebookId}
onLabelsCreated={() => {
// Refresh to see new labels
router.refresh()
}}
/>
{/* Note Editor Modal */}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</main>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import { SettingsSection } from '@/components/settings'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
export default function AboutSettingsPage() {
const { t } = useLanguage()
const version = '1.0.0'
const buildDate = '2026-01-17'
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('about.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('about.description')}
</p>
</div>
<SettingsSection
title={t('about.appName')}
icon={<span className="text-2xl">📝</span>}
description={t('about.appDescription')}
>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex justify-between items-center">
<span className="font-medium">{t('about.version')}</span>
<Badge variant="secondary">{version}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">{t('about.buildDate')}</span>
<Badge variant="outline">{buildDate}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="font-medium">{t('about.platform')}</span>
<Badge variant="outline">{t('about.platformWeb')}</Badge>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title={t('about.features.title')}
icon={<span className="text-2xl"></span>}
description={t('about.features.description')}
>
<Card>
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.titleSuggestions')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.semanticSearch')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.paragraphReformulation')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.memoryEcho')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.notebookOrganization')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.dragDrop')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.labelSystem')}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-green-500"></span>
<span>{t('about.features.multipleProviders')}</span>
</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title={t('about.technology.title')}
icon={<span className="text-2xl"></span>}
description={t('about.technology.description')}
>
<Card>
<CardContent className="pt-6 space-y-2 text-sm">
<div><strong>{t('about.technology.frontend')}:</strong> Next.js 16, React 19, TypeScript</div>
<div><strong>{t('about.technology.backend')}:</strong> Next.js API Routes, Server Actions</div>
<div><strong>{t('about.technology.database')}:</strong> SQLite (Prisma ORM)</div>
<div><strong>{t('about.technology.authentication')}:</strong> NextAuth 5</div>
<div><strong>{t('about.technology.ai')}:</strong> Vercel AI SDK, OpenAI, Ollama</div>
<div><strong>{t('about.technology.ui')}:</strong> Radix UI, Tailwind CSS, Lucide Icons</div>
<div><strong>{t('about.technology.testing')}:</strong> Playwright (E2E)</div>
</CardContent>
</Card>
</SettingsSection>
<SettingsSection
title={t('about.support.title')}
icon={<span className="text-2xl">💬</span>}
description={t('about.support.description')}
>
<Card>
<CardContent className="pt-6 space-y-4">
<div>
<p className="font-medium mb-2">{t('about.support.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">{t('about.support.reportIssues')}</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">{t('about.support.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

@@ -0,0 +1,16 @@
'use client'
import { useLanguage } from '@/lib/i18n'
export function AISettingsHeader() {
const { t } = useLanguage()
return (
<div className="mb-6">
<h1 className="text-3xl font-bold">{t('aiSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('aiSettings.description')}
</p>
</div>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect, SettingInput } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export default function AISettingsPage() {
const { t } = useLanguage()
const [apiKey, setApiKey] = useState('')
// Mock settings state - in real implementation, load from server
const [settings, setSettings] = useState({
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as 'daily' | 'weekly' | 'custom',
aiProvider: 'auto' as 'auto' | 'openai' | 'ollama',
preferredLanguage: 'auto' as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: false
})
const handleToggle = async (feature: string, value: boolean) => {
setSettings(prev => ({ ...prev, [feature]: value }))
try {
await updateAISettings({ [feature]: value })
} catch (error) {
toast.error(t('aiSettings.error'))
setSettings(settings) // Revert on error
}
}
const handleFrequencyChange = async (value: string) => {
setSettings(prev => ({ ...prev, memoryEchoFrequency: value as any }))
try {
await updateAISettings({ memoryEchoFrequency: value as any })
} catch (error) {
toast.error(t('aiSettings.error'))
}
}
const handleProviderChange = async (value: string) => {
setSettings(prev => ({ ...prev, aiProvider: value as any }))
try {
await updateAISettings({ aiProvider: value as any })
} catch (error) {
toast.error(t('aiSettings.error'))
}
}
const handleApiKeyChange = async (value: string) => {
setApiKey(value)
// TODO: Implement API key persistence
}
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">{t('aiSettings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('aiSettings.description')}
</p>
</div>
{/* AI Provider */}
<SettingsSection
title={t('aiSettings.provider')}
icon={<span className="text-2xl">🤖</span>}
description={t('aiSettings.providerDesc')}
>
<SettingSelect
label={t('aiSettings.provider')}
description={t('aiSettings.providerDesc')}
value={settings.aiProvider}
options={[
{
value: 'auto',
label: t('aiSettings.providerAuto'),
description: t('aiSettings.providerAutoDesc')
},
{
value: 'ollama',
label: t('aiSettings.providerOllama'),
description: t('aiSettings.providerOllamaDesc')
},
{
value: 'openai',
label: t('aiSettings.providerOpenAI'),
description: t('aiSettings.providerOpenAIDesc')
},
]}
onChange={handleProviderChange}
/>
{settings.aiProvider === 'openai' && (
<SettingInput
label={t('admin.ai.apiKey')}
description={t('admin.ai.openAIKeyDescription')}
value={apiKey}
type="password"
placeholder="sk-..."
onChange={handleApiKeyChange}
/>
)}
</SettingsSection>
{/* Feature Toggles */}
<SettingsSection
title={t('aiSettings.features')}
icon={<span className="text-2xl"></span>}
description={t('aiSettings.description')}
>
<SettingToggle
label={t('titleSuggestions.available').replace('💡 ', '')}
description={t('aiSettings.titleSuggestionsDesc')}
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<SettingToggle
label={t('semanticSearch.exactMatch')}
description={t('semanticSearch.searching')}
checked={settings.semanticSearch}
onChange={(checked) => handleToggle('semanticSearch', checked)}
/>
<SettingToggle
label={t('paragraphRefactor.title')}
description={t('aiSettings.paragraphRefactorDesc')}
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
<SettingToggle
label={t('memoryEcho.title')}
description={t('memoryEcho.dailyInsight')}
checked={settings.memoryEcho}
onChange={(checked) => handleToggle('memoryEcho', checked)}
/>
{settings.memoryEcho && (
<SettingSelect
label={t('aiSettings.frequency')}
description={t('aiSettings.frequencyDesc')}
value={settings.memoryEchoFrequency}
options={[
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
{ value: 'custom', label: 'Custom' },
]}
onChange={handleFrequencyChange}
/>
)}
</SettingsSection>
{/* Demo Mode */}
<SettingsSection
title={t('demoMode.title')}
icon={<span className="text-2xl">🎭</span>}
description={t('demoMode.description')}
>
<SettingToggle
label={t('demoMode.title')}
description={t('demoMode.description')}
checked={settings.demoMode}
onChange={(checked) => handleToggle('demoMode', checked)}
/>
</SettingsSection>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { AISettingsPanel } from '@/components/ai/ai-settings-panel'
import { getAISettings } from '@/app/actions/ai-settings'
import { AISettingsHeader } from './ai-settings-header'
export default async function AISettingsPage() {
const session = await auth()
if (!session?.user) {
redirect('/api/auth/signin')
}
const settings = await getAISettings()
return (
<div className="space-y-6">
<AISettingsHeader />
<AISettingsPanel initialSettings={settings} />
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { SettingsSection, SettingSelect } from '@/components/settings'
import { updateAISettings as updateAI } from '@/app/actions/ai-settings'
import { updateUserSettings as updateUser } from '@/app/actions/user-settings'
import { useLanguage } from '@/lib/i18n'
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 { t } = useLanguage()
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 if (value === 'light') {
root.setAttribute('data-theme', 'light')
} else {
root.setAttribute('data-theme', value)
if (['midnight', 'blue', 'sepia'].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' | 'blue' })
}
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">{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: 'slate', label: t('settings.themeLight') },
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'sepia', label: 'Sepia' },
{ value: 'midnight', label: 'Midnight' },
{ value: 'blue', label: 'Blue' },
{ 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>
)
}

View File

@@ -0,0 +1,113 @@
'use 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' })
}
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 })
}
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>
)
}

View File

@@ -0,0 +1,189 @@
'use client'
import { useState } from 'react'
import { SettingsSection } from '@/components/settings'
import { Button } from '@/components/ui/button'
import { Download, Upload, Trash2, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export default function DataSettingsPage() {
const { t } = useLanguage()
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const handleExport = async () => {
setIsExporting(true)
try {
const response = await fetch('/api/notes/export')
if (response.ok) {
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `memento-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success(t('dataManagement.export.success'))
}
} catch (error) {
console.error('Export error:', error)
toast.error(t('dataManagement.export.failed'))
} finally {
setIsExporting(false)
}
}
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setIsImporting(true)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/notes/import', {
method: 'POST',
body: formData
})
if (response.ok) {
const result = await response.json()
toast.success(t('dataManagement.import.success', { count: result.count }))
window.location.reload()
} else {
throw new Error('Import failed')
}
} catch (error) {
console.error('Import error:', error)
toast.error(t('dataManagement.import.failed'))
} finally {
setIsImporting(false)
event.target.value = ''
}
}
const handleDeleteAll = async () => {
if (!confirm(t('dataManagement.delete.confirm'))) {
return
}
setIsDeleting(true)
try {
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
if (response.ok) {
toast.success(t('dataManagement.delete.success'))
window.location.reload()
}
} catch (error) {
console.error('Delete error:', error)
toast.error(t('dataManagement.delete.failed'))
} finally {
setIsDeleting(false)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('dataManagement.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('dataManagement.toolsDescription')}
</p>
</div>
<SettingsSection
title={`💾 ${t('dataManagement.export.title')}`}
icon={<span className="text-2xl">💾</span>}
description={t('dataManagement.export.description')}
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">{t('dataManagement.export.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('dataManagement.export.description')}
</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 ? t('dataManagement.exporting') : t('dataManagement.export.button')}
</Button>
</div>
</SettingsSection>
<SettingsSection
title={`📥 ${t('dataManagement.import.title')}`}
icon={<span className="text-2xl">📥</span>}
description={t('dataManagement.import.description')}
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">{t('dataManagement.import.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('dataManagement.import.description')}
</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 ? t('dataManagement.importing') : t('dataManagement.import.button')}
</Button>
</div>
</div>
</SettingsSection>
<SettingsSection
title={`⚠️ ${t('dataManagement.dangerZone')}`}
icon={<span className="text-2xl"></span>}
description={t('dataManagement.dangerZoneDescription')}
>
<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">{t('dataManagement.delete.title')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('dataManagement.delete.description')}
</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 ? t('dataManagement.deleting') : t('dataManagement.delete.button')}
</Button>
</div>
</SettingsSection>
</div>
)
}

View File

@@ -0,0 +1,140 @@
'use 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'
export default function GeneralSettingsPage() {
const { t, setLanguage: setContextLanguage } = 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)
// 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. Force reload to ensure all components update (server components, metadata, etc.)
setTimeout(() => window.location.reload(), 500)
}
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="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>
)
}

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

@@ -0,0 +1,213 @@
'use client'
import React, { useState } from 'react'
import { SettingsNav, SettingsSection } from '@/components/settings'
import { Button } from '@/components/ui/button'
import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react'
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import Link from 'next/link'
export default function SettingsPage() {
const { t } = useLanguage()
const [loading, setLoading] = useState(false)
const [cleanupLoading, setCleanupLoading] = useState(false)
const [syncLoading, setSyncLoading] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [result, setResult] = useState<any>(null)
const [config, setConfig] = useState<any>(null)
const checkConnection = async () => {
setLoading(true)
setStatus('idle')
setResult(null)
try {
const res = await fetch('/api/ai/test')
const data = await res.json()
setConfig({
provider: data.provider,
status: res.ok ? 'connected' : 'disconnected'
})
if (res.ok) {
setStatus('success')
setResult(data)
} else {
setStatus('error')
setResult(data)
}
} catch (error: any) {
console.error(error)
setStatus('error')
setResult({ message: error.message, stack: error.stack })
} finally {
setLoading(false)
}
}
const handleSync = async () => {
setSyncLoading(true)
try {
const result = await syncAllEmbeddings()
if (result.success) {
toast.success(`Indexing complete: ${result.count} notes processed`)
}
} catch (error) {
console.error(error)
toast.error("Error during indexing")
} finally {
setSyncLoading(false)
}
}
const handleCleanup = async () => {
setCleanupLoading(true)
try {
const result = await cleanupAllOrphans()
if (result.success) {
toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`)
}
} catch (error) {
console.error(error)
toast.error("Error during cleanup")
} finally {
setCleanupLoading(false)
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{t('settings.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('settings.description')}
</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">{t('aiSettings.title')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('aiSettings.description')}
</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-primary mb-2" />
<h3 className="font-semibold">{t('profile.title')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('profile.description')}
</p>
</div>
</Link>
</div>
{/* AI Diagnostics */}
<SettingsSection
title={t('diagnostics.title')}
icon={<span className="text-2xl">🔍</span>}
description={t('diagnostics.description')}
>
<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">{t('diagnostics.configuredProvider')}</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">{t('diagnostics.apiStatus')}</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' ? t('diagnostics.operational') :
status === 'error' ? t('diagnostics.errorStatus') :
t('diagnostics.checking')}
</span>
</div>
</div>
</div>
{result && (
<div className="space-y-2 mt-4">
<h3 className="text-sm font-medium">{t('diagnostics.testDetails')}</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">{t('diagnostics.troubleshootingTitle')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>{t('diagnostics.tip1')}</li>
<li>{t('diagnostics.tip2')}</li>
<li>{t('diagnostics.tip3')}</li>
<li>{t('diagnostics.tip4')}</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" />}
{t('general.testConnection')}
</Button>
</div>
</SettingsSection>
{/* Maintenance */}
<SettingsSection
title={t('settings.maintenance')}
icon={<span className="text-2xl">🔧</span>}
description={t('settings.maintenanceDescription')}
>
<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">
{t('settings.cleanTags')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('settings.cleanTagsDescription')}
</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" />}
{t('general.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">
{t('settings.semanticIndexing')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('settings.semanticIndexingDescription')}
</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" />}
{t('general.indexAll')}
</Button>
</div>
</div>
</SettingsSection>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ArrowRight, Sparkles } from 'lucide-react'
import Link from 'next/link'
import { useLanguage } from '@/lib/i18n'
export function AISettingsLinkCard() {
const { t } = useLanguage()
return (
<Card className="mt-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
{t('nav.aiSettings')}
</CardTitle>
<CardDescription>
{t('nav.configureAI')}
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/settings/ai">
<Button variant="outline" className="w-full justify-between group">
<span>{t('nav.manageAISettings')}</span>
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
</Link>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,155 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingInput, SettingSelect } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export default function ProfileSettingsPage() {
const { t } = useLanguage()
// Mock user data - in real implementation, load from server
const [user, setUser] = useState({
name: 'John Doe',
email: 'john@example.com'
})
const [language, setLanguage] = useState('auto')
const [showRecentNotes, setShowRecentNotes] = useState(false)
const handleNameChange = async (value: string) => {
setUser(prev => ({ ...prev, name: value }))
// TODO: Implement profile update
}
const handleEmailChange = async (value: string) => {
setUser(prev => ({ ...prev, email: value }))
// TODO: Implement email update
}
const handleLanguageChange = async (value: string) => {
setLanguage(value)
try {
await updateAISettings({ preferredLanguage: value as any })
} catch (error) {
console.error('Error updating language:', error)
toast.error(t('aiSettings.error'))
}
}
const handleRecentNotesChange = async (enabled: boolean) => {
setShowRecentNotes(enabled)
try {
await updateAISettings({ showRecentNotes: enabled })
} catch (error) {
console.error('Error updating recent notes setting:', error)
toast.error(t('aiSettings.error'))
}
}
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">{t('profile.title')}</h1>
<p className="text-gray-600 dark:text-gray-400">
{t('profile.description')}
</p>
</div>
{/* Profile Information */}
<SettingsSection
title={t('profile.accountSettings')}
icon={<span className="text-2xl">👤</span>}
description={t('profile.description')}
>
<SettingInput
label={t('profile.displayName')}
description={t('profile.displayName')}
value={user.name}
onChange={handleNameChange}
placeholder={t('auth.namePlaceholder')}
/>
<SettingInput
label={t('profile.email')}
description={t('profile.email')}
value={user.email}
type="email"
onChange={handleEmailChange}
placeholder={t('auth.emailPlaceholder')}
/>
</SettingsSection>
{/* Preferences */}
<SettingsSection
title={t('settings.language')}
icon={<span className="text-2xl"></span>}
description={t('profile.languagePreferencesDescription')}
>
<SettingSelect
label={t('profile.preferredLanguage')}
description={t('profile.languageDescription')}
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}
/>
<SettingToggle
label={t('profile.showRecentNotes')}
description={t('profile.showRecentNotesDescription')}
checked={showRecentNotes}
onChange={handleRecentNotesChange}
/>
</SettingsSection>
{/* AI Settings Link */}
<div className="p-6 border rounded-lg bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950 dark:to-pink-950">
<div className="flex items-center gap-4">
<div className="text-4xl"></div>
<div className="flex-1">
<h3 className="font-semibold text-lg mb-1">{t('aiSettings.title')}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('aiSettings.description')}
</p>
</div>
<button
onClick={() => window.location.href = '/settings/ai'}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
{t('nav.configureAI')}
</button>
</div>
</div>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { ProfileForm } from './profile-form'
import prisma from '@/lib/prisma'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Sparkles } from 'lucide-react'
import { ProfilePageHeader } from '@/components/profile-page-header'
import { AISettingsLinkCard } from './ai-settings-link-card'
export default async function ProfilePage() {
const session = await auth()
if (!session?.user?.id) {
redirect('/login')
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { name: true, email: true, role: true }
})
if (!user) {
redirect('/login')
}
// Get user AI settings
let userAISettings = { preferredLanguage: 'auto', showRecentNotes: false }
try {
const aiSettings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (aiSettings) {
userAISettings = {
preferredLanguage: aiSettings.preferredLanguage || 'auto',
showRecentNotes: aiSettings.showRecentNotes ?? false
}
}
} catch (error) {
console.error('Error fetching AI settings:', error)
}
return (
<div className="max-w-2xl">
<ProfilePageHeader />
<ProfileForm user={user} userAISettings={userAISettings} />
{/* AI Settings Link */}
<AISettingsLinkCard />
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { updateProfile, changePassword, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
const router = useRouter()
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
const [isUpdatingRecentNotes, setIsUpdatingRecentNotes] = useState(false)
const { t } = useLanguage()
const FONT_SIZES = [
{ value: 'small', label: t('profile.fontSizeSmall'), size: '14px' },
{ value: 'medium', label: t('profile.fontSizeMedium'), size: '16px' },
{ value: 'large', label: t('profile.fontSizeLarge'), size: '18px' },
{ value: 'extra-large', label: t('profile.fontSizeExtraLarge'), size: '20px' },
]
const handleFontSizeChange = async (size: string) => {
setIsUpdatingFontSize(true)
try {
const result = await updateFontSize(size)
if (result?.error) {
toast.error(t('profile.fontSizeUpdateFailed'))
} else {
setFontSize(size)
// Apply font size immediately
applyFontSize(size)
toast.success(t('profile.fontSizeUpdateSuccess'))
}
} catch (error) {
toast.error(t('profile.fontSizeUpdateFailed'))
} finally {
setIsUpdatingFontSize(false)
}
}
const applyFontSize = (size: string) => {
// Base font size in pixels (16px is standard)
const fontSizeMap = {
'small': '14px', // ~87% of 16px
'medium': '16px', // 100% (standard)
'large': '18px', // ~112% of 16px
'extra-large': '20px' // 125% of 16px
}
const fontSizeFactorMap = {
'small': 0.95,
'medium': 1.0,
'large': 1.1,
'extra-large': 1.25
}
const fontSizeValue = fontSizeMap[size as keyof typeof fontSizeMap] || '16px'
const fontSizeFactor = fontSizeFactorMap[size as keyof typeof fontSizeFactorMap] || 1.0
document.documentElement.style.setProperty('--user-font-size', fontSizeValue)
document.documentElement.style.setProperty('--user-font-size-factor', fontSizeFactor.toString())
localStorage.setItem('user-font-size', size)
}
// Apply saved font size on mount
useEffect(() => {
const savedFontSize = localStorage.getItem('user-font-size') || userAISettings?.fontSize || 'medium'
applyFontSize(savedFontSize as string)
}, [])
const handleShowRecentNotesChange = async (enabled: boolean) => {
setIsUpdatingRecentNotes(true)
const previousValue = showRecentNotes
try {
const result = await updateShowRecentNotes(enabled)
if (result?.error) {
toast.error(result.error)
} else {
setShowRecentNotes(enabled)
toast.success(t('profile.recentNotesUpdateSuccess') || 'Paramètre mis à jour')
// Force full page reload to ensure settings are reloaded
window.location.href = '/settings/profile'
}
} catch (error: any) {
setShowRecentNotes(previousValue)
toast.error(error?.message || 'Erreur')
} finally {
setIsUpdatingRecentNotes(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('profile.title')}</CardTitle>
<CardDescription>{t('profile.description')}</CardDescription>
</CardHeader>
<form action={async (formData) => {
const result = await updateProfile({ name: formData.get('name') as string })
if (result?.error) {
toast.error(t('profile.updateFailed'))
} else {
toast.success(t('profile.updateSuccess'))
}
}}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.displayName')}</label>
<Input id="name" name="name" defaultValue={user.name} />
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.email')}</label>
<Input id="email" value={user.email} disabled className="bg-muted" />
</div>
</CardContent>
<CardFooter>
<Button type="submit">{t('general.save')}</Button>
</CardFooter>
</form>
</Card>
<Card>
<CardHeader>
<CardTitle>{t('profile.changePassword')}</CardTitle>
<CardDescription>{t('profile.changePasswordDescription')}</CardDescription>
</CardHeader>
<form action={async (formData) => {
const result = await changePassword(formData)
if (result?.error) {
const msg = '_form' in result.error
? result.error._form[0]
: result.error.currentPassword?.[0] || result.error.newPassword?.[0] || result.error.confirmPassword?.[0] || t('profile.passwordChangeFailed')
toast.error(msg)
} else {
toast.success(t('profile.passwordChangeSuccess'))
// Reset form manually or redirect
const form = document.querySelector('form#password-form') as HTMLFormElement
form?.reset()
}
}} id="password-form">
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="currentPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.currentPassword')}</label>
<Input id="currentPassword" name="currentPassword" type="password" required />
</div>
<div className="space-y-2">
<label htmlFor="newPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.newPassword')}</label>
<Input id="newPassword" name="newPassword" type="password" required minLength={6} />
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">{t('profile.confirmPassword')}</label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minLength={6} />
</div>
</CardContent>
<CardFooter>
<Button type="submit">{t('profile.updatePassword')}</Button>
</CardFooter>
</form>
</Card>
</div>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useLanguage } from '@/lib/i18n';
export default function SupportPage() {
const { t } = useLanguage();
return (
<div className="container mx-auto py-10 max-w-4xl">
<div className="text-center mb-10">
<h1 className="text-4xl font-bold mb-4">
{t('support.title')}
</h1>
<p className="text-muted-foreground text-lg">
{t('support.description')}
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-10">
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl"></span>
{t('support.buyMeACoffee')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.donationDescription')}
</p>
<Button asChild className="w-full">
<a href="https://ko-fi.com/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.donateOnKofi')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.kofiDescription')}
</p>
</CardContent>
</Card>
<Card className="border-2 border-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">💚</span>
{t('support.sponsorOnGithub')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4">
{t('support.sponsorDescription')}
</p>
<Button asChild variant="outline" className="w-full">
<a href="https://github.com/sponsors/yourusername" target="_blank" rel="noopener noreferrer">
{t('support.sponsorOnGithub')}
</a>
</Button>
<p className="text-xs text-muted-foreground mt-2">
{t('support.githubDescription')}
</p>
</CardContent>
</Card>
</div>
<Card className="mb-10">
<CardHeader>
<CardTitle>{t('support.howSupportHelps')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-4">
<div>
<h3 className="font-semibold mb-2">💰 {t('support.directImpact')}</h3>
<ul className="space-y-2 text-sm">
<li> Keeps me fueled with coffee</li>
<li>🐛 Covers hosting and server costs</li>
<li> Funds development of new features</li>
<li>📚 Improves documentation</li>
<li>🌍 Keeps Memento 100% open-source</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-2">🎁 {t('support.sponsorPerks')}</h3>
<ul className="space-y-2 text-sm">
<li>🥉 $5/month - Bronze: Name in supporters list</li>
<li>🥈 $15/month - Silver: Priority feature requests</li>
<li>🥇 $50/month - Gold: Logo in footer, priority support</li>
<li>💎 $100/month - Platinum: Custom features, consulting</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>💡 {t('support.transparency')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
{t('support.transparencyDescription')}
</p>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>{t('support.hostingServers')}</span>
<span className="font-mono">~$20/month</span>
</div>
<div className="flex justify-between">
<span>{t('support.domainSSL')}</span>
<span className="font-mono">~$15/year</span>
</div>
<div className="flex justify-between">
<span>{t('support.aiApiCosts')}</span>
<span className="font-mono">~$30/month</span>
</div>
<div className="flex justify-between border-t pt-2">
<span className="font-semibold">{t('support.totalExpenses')}</span>
<span className="font-mono font-semibold">~$50/month</span>
</div>
</div>
<p className="text-xs text-muted-foreground mt-4">
Any amount beyond these costs goes directly into improving Memento
and funding new features. Thank you for your support! 💚
</p>
</CardContent>
</Card>
<div className="mt-10 text-center">
<h2 className="text-2xl font-bold mb-4">{t('support.otherWaysTitle')}</h2>
<div className="flex flex-wrap justify-center gap-4">
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
{t('support.starGithub')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento/issues" target="_blank" rel="noopener noreferrer">
🐛 {t('support.reportBug')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
📝 {t('support.contributeCode')}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://twitter.com/intent/tweet?text=Check%20out%20Memento%20-%20a%20great%20open-source%20note-taking%20app!%20https://github.com/yourusername/memento" target="_blank" rel="noopener noreferrer">
🐦 {t('support.shareTwitter')}
</a>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { Trash2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
export const dynamic = 'force-dynamic'
export default function TrashPage() {
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<TrashContent />
</main>
)
}
function TrashContent() {
const { t } = useLanguage()
return (
<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">{t('trash.empty')}</h2>
<p className="max-w-md text-sm opacity-80">
{t('trash.restore')}
</p>
</div>
)
}

View File

@@ -0,0 +1,66 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { sendEmail } from '@/lib/mail'
async function checkAdmin() {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
throw new Error('Unauthorized: Admin access required')
}
return session
}
export async function testSMTP() {
const session = await checkAdmin()
const email = session.user?.email
if (!email) throw new Error("No admin email found")
const result = await sendEmail({
to: email,
subject: "Memento SMTP Test",
html: "<p>This is a test email from your Memento instance. <strong>SMTP is working!</strong></p>"
})
return result
}
export async function getSystemConfig() {
await checkAdmin()
const configs = await prisma.systemConfig.findMany()
return configs.reduce((acc, conf) => {
acc[conf.key] = conf.value
return acc
}, {} as Record<string, string>)
}
export async function updateSystemConfig(data: Record<string, string>) {
await checkAdmin()
try {
// Filter out empty values but keep 'false' as valid
const filteredData = Object.fromEntries(
Object.entries(data).filter(([key, value]) => value !== '' && value !== null && value !== undefined)
)
const operations = Object.entries(filteredData).map(([key, value]) =>
prisma.systemConfig.upsert({
where: { key },
update: { value },
create: { key, value }
})
)
await prisma.$transaction(operations)
revalidatePath('/admin/settings')
return { success: true }
} catch (error) {
console.error('Failed to update settings:', error)
return { error: 'Failed to update settings' }
}
}

View File

@@ -0,0 +1,120 @@
'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
// Schema pour la création d'utilisateur
const CreateUserSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
password: z.string().min(6, "Password must be at least 6 characters"),
role: z.enum(["USER", "ADMIN"]).default("USER"),
})
async function checkAdmin() {
const session = await auth()
if (!session?.user?.id || (session.user as any).role !== 'ADMIN') {
throw new Error('Unauthorized: Admin access required')
}
return session
}
export async function getUsers() {
await checkAdmin()
try {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
}
})
return users
} catch (error) {
console.error('Failed to fetch users:', error)
throw new Error('Failed to fetch users')
}
}
export async function createUser(formData: FormData) {
await checkAdmin()
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
role: formData.get('role'),
}
const validatedFields = CreateUserSchema.safeParse(rawData)
if (!validatedFields.success) {
return {
error: validatedFields.error.flatten().fieldErrors,
}
}
const { name, email, password, role } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, 10)
try {
await prisma.user.create({
data: {
name,
email: email.toLowerCase(),
password: hashedPassword,
role,
},
})
revalidatePath('/admin')
return { success: true }
} catch (error: any) {
if (error.code === 'P2002') {
return { error: { email: ['Email already exists'] } }
}
return { error: { _form: ['Failed to create user'] } }
}
}
export async function deleteUser(userId: string) {
const session = await checkAdmin()
if (session.user?.id === userId) {
throw new Error("You cannot delete your own account")
}
try {
await prisma.user.delete({
where: { id: userId },
})
revalidatePath('/admin')
return { success: true }
} catch (error) {
throw new Error('Failed to delete user')
}
}
export async function updateUserRole(userId: string, newRole: string) {
const session = await checkAdmin()
if (session.user?.id === userId) {
throw new Error("You cannot change your own role")
}
try {
await prisma.user.update({
where: { id: userId },
data: { role: newRole },
})
revalidatePath('/admin')
return { success: true }
} catch (error) {
throw new Error('Failed to update role')
}
}

View File

@@ -0,0 +1,382 @@
'use server'
/**
* AI Server Actions Stub File
*
* This file provides a centralized location for all AI-related server action interfaces
* and serves as documentation for the AI server action architecture.
*
* IMPLEMENTATION STATUS:
* - Title Suggestions: ✅ Implemented (see app/actions/title-suggestions.ts)
* - Semantic Search: ✅ Implemented (see app/actions/semantic-search.ts)
* - Paragraph Reformulation: ✅ Implemented (see app/actions/paragraph-refactor.ts)
* - Memory Echo: ⏳ STUB - To be implemented in Epic 5 (Story 5-1)
* - Language Detection: ✅ Implemented (see app/actions/detect-language.ts)
* - AI Settings: ✅ Implemented (see app/actions/ai-settings.ts)
*
* NOTE: This file defines TypeScript interfaces and placeholder functions.
* Actual implementations are in separate action files (see references above).
*/
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
// ============================================================================
// TYPESCRIPT INTERFACES
// ============================================================================
/**
* Title Suggestions Interfaces
* @see app/actions/title-suggestions.ts for implementation
*/
export interface GenerateTitlesRequest {
noteId: string
}
export interface GenerateTitlesResponse {
suggestions: Array<{
title: string
confidence: number
reasoning?: string
}>
noteId: string
}
/**
* Semantic Search Interfaces
* @see app/actions/semantic-search.ts for implementation
*/
export interface SearchResult {
noteId: string
title: string | null
content: string
similarity: number
matchType: 'exact' | 'related'
}
export interface SemanticSearchRequest {
query: string
options?: {
limit?: number
threshold?: number
notebookId?: string
}
}
export interface SemanticSearchResponse {
results: SearchResult[]
query: string
totalResults: number
}
/**
* Paragraph Reformulation Interfaces
* @see app/actions/paragraph-refactor.ts for implementation
*/
export type RefactorMode = 'clarify' | 'shorten' | 'improve'
export interface RefactorParagraphRequest {
noteId: string
selectedText: string
option: RefactorMode
}
export interface RefactorParagraphResponse {
originalText: string
refactoredText: string
}
/**
* Memory Echo Interfaces
* STUB - To be implemented in Epic 5 (Story 5-1)
*
* This feature will analyze all user notes with embeddings to find
* connections with cosine similarity > 0.75 and provide proactive insights.
*/
export interface GenerateMemoryEchoRequest {
// No params - uses current user session
}
export interface MemoryEchoInsight {
note1Id: string
note2Id: string
similarityScore: number
}
export interface GenerateMemoryEchoResponse {
success: boolean
insight: MemoryEchoInsight | null
}
/**
* Language Detection Interfaces
* @see app/actions/detect-language.ts for implementation
*/
export interface DetectLanguageRequest {
content: string
}
export interface DetectLanguageResponse {
language: string
confidence: number
method: 'tinyld' | 'ai'
}
/**
* AI Settings Interfaces
* @see app/actions/ai-settings.ts for implementation
*/
export interface AISettingsConfig {
titleSuggestions?: boolean
semanticSearch?: boolean
paragraphRefactor?: boolean
memoryEcho?: boolean
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
aiProvider?: 'auto' | 'openai' | 'ollama'
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
}
export interface UpdateAISettingsRequest {
settings: Partial<AISettingsConfig>
}
export interface UpdateAISettingsResponse {
success: boolean
}
// ============================================================================
// PLACEHOLDER FUNCTIONS
// ============================================================================
/**
* Generate Title Suggestions
*
* ALREADY IMPLEMENTED: See app/actions/title-suggestions.ts
*
* This function generates 3 AI-powered title suggestions for a note when it
* reaches 50+ words without a title.
*
* @see generateTitleSuggestions in app/actions/title-suggestions.ts
*/
export async function generateTitles(
request: GenerateTitlesRequest
): Promise<GenerateTitlesResponse> {
// TODO: Import and use implementation from title-suggestions.ts
// import { generateTitleSuggestions } from './title-suggestions'
// return generateTitleSuggestions(request.noteId)
throw new Error('Not implemented in stub: Use app/actions/title-suggestions.ts')
}
/**
* Semantic Search
*
* ALREADY IMPLEMENTED: See app/actions/semantic-search.ts
*
* This function performs hybrid semantic + keyword search across user notes.
*
* @see semanticSearch in app/actions/semantic-search.ts
*/
export async function semanticSearch(
request: SemanticSearchRequest
): Promise<SemanticSearchResponse> {
// TODO: Import and use implementation from semantic-search.ts
// import { semanticSearch } from './semantic-search'
// return semanticSearch(request.query, request.options)
throw new Error('Not implemented in stub: Use app/actions/semantic-search.ts')
}
/**
* Refactor Paragraph
*
* ALREADY IMPLEMENTED: See app/actions/paragraph-refactor.ts
*
* This function refactors a paragraph using AI with specific mode (clarify/shorten/improve).
*
* @see refactorParagraph in app/actions/paragraph-refactor.ts
*/
export async function refactorParagraph(
request: RefactorParagraphRequest
): Promise<RefactorParagraphResponse> {
// TODO: Import and use implementation from paragraph-refactor.ts
// import { refactorParagraph } from './paragraph-refactor'
// return refactorParagraph(request.selectedText, request.option)
throw new Error('Not implemented in stub: Use app/actions/paragraph-refactor.ts')
}
/**
* Generate Memory Echo Insights
*
* STUB: To be implemented in Epic 5 (Story 5-1)
*
* This will analyze all user notes with embeddings to find
* connections with cosine similarity > 0.75.
*
* Implementation Plan:
* - Fetch all user notes with embeddings
* - Calculate pairwise cosine similarities
* - Find top connection with similarity > 0.75
* - Store in MemoryEchoInsight table
* - Return insight or null if none found
*
* @see Epic 5 Story 5-1 in planning/epics.md
*/
export async function generateMemoryEcho(): Promise<GenerateMemoryEchoResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// TODO: Implement Memory Echo background processing
// - Fetch all user notes with embeddings from prisma.note
// - Calculate pairwise cosine similarities using embedding vectors
// - Filter for similarity > 0.75
// - Select top insight
// - Store in prisma.memoryEchoInsight table (if it exists)
// - Return { success: true, insight: {...} }
throw new Error('Not implemented: See Epic 5 Story 5-1')
}
/**
* Detect Language
*
* ALREADY IMPLEMENTED: See app/actions/detect-language.ts
*
* This function detects the language of user content.
*
* @see getInitialLanguage in app/actions/detect-language.ts
*/
export async function detectLanguage(
request: DetectLanguageRequest
): Promise<DetectLanguageResponse> {
// TODO: Import and use implementation from detect-language.ts
// import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
// const language = await detectUserLanguage()
// return { language, confidence: 0.95, method: 'tinyld' }
throw new Error('Not implemented in stub: Use app/actions/detect-language.ts')
}
/**
* Update AI Settings
*
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
*
* This function updates user AI preferences.
*
* @see updateAISettings in app/actions/ai-settings.ts
*/
export async function updateAISettings(
request: UpdateAISettingsRequest
): Promise<UpdateAISettingsResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// TODO: Import and use implementation from ai-settings.ts
// import { updateAISettings } from './ai-settings'
// return updateAISettings(request.settings)
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
}
/**
* Get AI Settings
*
* ALREADY IMPLEMENTED: See app/actions/ai-settings.ts
*
* This function retrieves user AI preferences.
*
* @see getAISettings in app/actions/ai-settings.ts
*/
export async function getAISettings(): Promise<AISettingsConfig> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
// TODO: Import and use implementation from ai-settings.ts
// import { getAISettings } from './ai-settings'
// return getAISettings()
throw new Error('Not implemented in stub: Use app/actions/ai-settings.ts')
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Check if a specific AI feature is enabled for the user
*
* UTILITY: Helper function to check feature flags
*
* @param feature - The AI feature to check
* @returns Promise<boolean> - Whether the feature is enabled
*/
export async function isAIFeatureEnabled(
feature: keyof AISettingsConfig
): Promise<boolean> {
const session = await auth()
if (!session?.user?.id) {
return false
}
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (!settings) {
// Default to enabled for new users
return true
}
switch (feature) {
case 'titleSuggestions':
return settings.titleSuggestions ?? true
case 'semanticSearch':
return settings.semanticSearch ?? true
case 'paragraphRefactor':
return settings.paragraphRefactor ?? true
case 'memoryEcho':
return settings.memoryEcho ?? true
default:
return true
}
} catch (error) {
console.error('Error checking AI feature enabled:', error)
return true
}
}
/**
* Get user's preferred AI provider
*
* UTILITY: Helper function to get provider preference
*
* @returns Promise<'auto' | 'openai' | 'ollama'> - The AI provider
*/
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
const session = await auth()
if (!session?.user?.id) {
return 'auto'
}
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
return (settings?.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama'
} catch (error) {
console.error('Error getting user AI preference:', error)
return 'auto'
}
}

View File

@@ -0,0 +1,190 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath, revalidateTag } from 'next/cache'
export type UserAISettingsData = {
titleSuggestions?: boolean
semanticSearch?: boolean
paragraphRefactor?: boolean
memoryEcho?: boolean
memoryEchoFrequency?: 'daily' | 'weekly' | 'custom'
aiProvider?: 'auto' | 'openai' | 'ollama'
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) {
const session = await auth()
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)
const result = await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
...settings
},
update: settings
})
revalidatePath('/settings/ai', 'page')
revalidatePath('/', 'layout')
revalidateTag('ai-settings')
return { success: true }
} catch (error) {
console.error('Error updating AI settings:', error)
throw new Error('Failed to update AI settings')
}
}
/**
* Get AI settings for the current user (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function to fetch settings from DB
const getCachedAISettings = unstable_cache(
async (userId: string) => {
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId }
})
if (!settings) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
return {
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,
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)
// Return defaults on error
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
},
['user-ai-settings'],
{ tags: ['ai-settings'] }
)
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 (!id) {
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false,
showRecentNotes: false,
emailNotifications: false,
desktopNotifications: false,
anonymousAnalytics: false,
theme: 'light' as const,
fontSize: 'medium' as const
}
}
return getCachedAISettings(id)
}
/**
* Get user's preferred AI provider
*/
export async function getUserAIPreference(): Promise<'auto' | 'openai' | 'ollama'> {
const settings = await getAISettings()
return settings.aiProvider
}
/**
* Check if a specific AI feature is enabled for the user
*/
export async function isAIFeatureEnabled(feature: keyof UserAISettingsData): Promise<boolean> {
const settings = await getAISettings()
switch (feature) {
case 'titleSuggestions':
return settings.titleSuggestions
case 'semanticSearch':
return settings.semanticSearch
case 'paragraphRefactor':
return settings.paragraphRefactor
case 'memoryEcho':
return settings.memoryEcho
default:
return true
}
}

View File

@@ -0,0 +1,86 @@
'use server'
import prisma from '@/lib/prisma'
import { sendEmail } from '@/lib/mail'
import bcrypt from 'bcryptjs'
import { getEmailTemplate } from '@/lib/email-template'
// Helper simple pour générer un token sans dépendance externe lourde
function generateToken() {
const array = new Uint8Array(32);
globalThis.crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
export async function forgotPassword(email: string) {
if (!email) return { error: "Email is required" };
try {
const user = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) {
// Pour des raisons de sécurité, on ne dit pas si l'email existe ou pas
return { success: true };
}
const token = generateToken();
const expiry = new Date(Date.now() + 3600000); // 1 hour
await prisma.user.update({
where: { id: user.id },
data: {
resetToken: token,
resetTokenExpiry: expiry
}
});
const resetLink = `${process.env.NEXTAUTH_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
const html = getEmailTemplate(
"Reset your Password",
"<p>You requested a password reset for your Memento account.</p><p>Click the button below to set a new password. This link is valid for 1 hour.</p>",
resetLink,
"Reset Password"
);
await sendEmail({
to: user.email,
subject: "Reset your Memento password",
html
});
return { success: true };
} catch (error) {
console.error('Forgot password error:', error);
return { error: "Failed to send reset email" };
}
}
export async function resetPassword(token: string, newPassword: string) {
if (!token || !newPassword) return { error: "Missing token or password" };
try {
const user = await prisma.user.findUnique({
where: { resetToken: token }
});
if (!user || !user.resetTokenExpiry || user.resetTokenExpiry < new Date()) {
return { error: "Invalid or expired token" };
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null
}
});
return { success: true };
} catch (error) {
console.error('Reset password error:', error);
return { error: "Failed to reset password" };
}
}

View File

@@ -0,0 +1,29 @@
'use server';
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
export async function authenticate(
prevState: string | undefined,
formData: FormData,
) {
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: '/',
});
} catch (error) {
if (error instanceof AuthError) {
console.error('AuthError details:', error.type, error.message);
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid credentials.';
default:
return `Auth error: ${error.type}`;
}
}
// IMPORTANT: Next.js redirects throw a special error that must be rethrown
throw error;
}
}

View File

@@ -0,0 +1,12 @@
'use server'
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
import { SupportedLanguage } from '@/lib/i18n/load-translations'
/**
* Server action to detect user's preferred language
* Called on app load to set initial language
*/
export async function getInitialLanguage(): Promise<SupportedLanguage> {
return await detectUserLanguage()
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
'use server'
interface OllamaModel {
name: string
modified_at: string
size: number
digest: string
details: {
format: string
family: string
families: string[]
parameter_size: string
quantization_level: string
}
}
interface OllamaTagsResponse {
models: OllamaModel[]
}
export async function getOllamaModels(baseUrl: string): Promise<{ success: boolean; models: string[]; error?: string }> {
if (!baseUrl) {
return { success: false, models: [], error: 'Base URL is required' }
}
// Ensure URL doesn't end with slash
const cleanUrl = baseUrl.replace(/\/$/, '')
try {
const response = await fetch(`${cleanUrl}/api/tags`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Set a reasonable timeout
signal: AbortSignal.timeout(5000)
})
if (!response.ok) {
throw new Error(`Ollama API returned ${response.status}: ${response.statusText}`)
}
const data = await response.json() as OllamaTagsResponse
// Extract model names
const modelNames = data.models?.map(m => m.name) || []
return { success: true, models: modelNames }
} catch (error: any) {
console.error('Failed to fetch Ollama models:', error)
return {
success: false,
models: [],
error: error.message || 'Failed to connect to Ollama'
}
}
}

View File

@@ -0,0 +1,49 @@
'use server'
import { paragraphRefactorService, RefactorMode, RefactorResult } from '@/lib/ai/services/paragraph-refactor.service'
export interface RefactorResponse {
result: RefactorResult
}
/**
* Refactor a paragraph with a specific mode
*/
export async function refactorParagraph(
content: string,
mode: RefactorMode
): Promise<RefactorResponse> {
try {
const result = await paragraphRefactorService.refactor(content, mode)
return { result }
} catch (error) {
console.error('Error refactoring paragraph:', error)
throw error
}
}
/**
* Get all 3 refactor options at once
*/
export async function refactorParagraphAllModes(
content: string
): Promise<{ results: RefactorResult[] }> {
try {
const results = await paragraphRefactorService.refactorAllModes(content)
return { results }
} catch (error) {
console.error('Error refactoring paragraph in all modes:', error)
throw error
}
}
/**
* Validate word count before refactoring
*/
export async function validateRefactorWordCount(
content: string
): Promise<{ valid: boolean; error?: string }> {
return paragraphRefactorService.validateWordCount(content)
}

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

@@ -0,0 +1,201 @@
'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 {
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
showRecentNotes,
// Defaults will be used for other fields
},
update: {
showRecentNotes,
},
})
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,63 @@
'use server';
import bcrypt from 'bcryptjs';
import prisma from '@/lib/prisma';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { getSystemConfig } from '@/lib/config';
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().min(2),
});
export async function register(prevState: string | undefined, formData: FormData) {
// Check if registration is allowed
const config = await getSystemConfig();
const allowRegister = config.ALLOW_REGISTRATION !== 'false' && process.env.ALLOW_REGISTRATION !== 'false';
if (!allowRegister) {
return 'Registration is currently disabled by the administrator.';
}
const validatedFields = RegisterSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
name: formData.get('name'),
});
if (!validatedFields.success) {
return 'Invalid fields. Failed to register.';
}
const { email, password, name } = validatedFields.data;
try {
const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (existingUser) {
return 'User already exists.';
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
email: email.toLowerCase(),
password: hashedPassword,
name,
},
});
// Attempt to sign in immediately after registration
// We cannot import signIn here directly if it causes circular deps or issues,
// but usually it works. If not, redirecting to login is fine.
// Let's stick to redirecting to login but with a clear success message?
// Or better: lowercase the email to fix the potential bug.
} catch (error) {
console.error('Registration Error:', error);
return 'Database Error: Failed to create user.';
}
redirect('/login');
}

View File

@@ -0,0 +1,59 @@
'use server'
import * as cheerio from 'cheerio';
export interface LinkMetadata {
url: string;
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
}
export async function fetchLinkMetadata(url: string): Promise<LinkMetadata | null> {
try {
// Add protocol if missing
let targetUrl = url;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
targetUrl = 'https://' + url;
}
const response = await fetch(targetUrl, {
headers: {
// Use a real browser User-Agent to avoid 403 Forbidden from strict sites
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5'
},
next: { revalidate: 3600 } // Cache for 1 hour
});
if (!response.ok) {
return null;
}
const html = await response.text();
const $ = cheerio.load(html);
const getMeta = (prop: string) =>
$(`meta[property="${prop}"]`).attr('content') ||
$(`meta[name="${prop}"]`).attr('content');
// Robust extraction with fallbacks
const title = getMeta('og:title') || $('title').text() || getMeta('twitter:title') || url;
const description = getMeta('og:description') || getMeta('description') || getMeta('twitter:description') || '';
const imageUrl = getMeta('og:image') || getMeta('twitter:image') || $('link[rel="image_src"]').attr('href');
const siteName = getMeta('og:site_name') || '';
return {
url: targetUrl,
title: title.substring(0, 100),
description: description.substring(0, 200),
imageUrl,
siteName
};
} catch (error) {
console.error(`[Scrape] Error fetching ${url}:`, error);
return null;
}
}

View File

@@ -0,0 +1,63 @@
'use server'
import { semanticSearchService, SearchResult } from '@/lib/ai/services/semantic-search.service'
export interface SemanticSearchResponse {
results: SearchResult[]
query: string
totalResults: number
}
/**
* Perform hybrid semantic + keyword search
* Supports contextual search within notebook (IA5)
*/
export async function semanticSearch(
query: string,
options?: {
limit?: number
threshold?: number
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
}
): Promise<SemanticSearchResponse> {
try {
const results = await semanticSearchService.search(query, {
limit: options?.limit || 20,
threshold: options?.threshold || 0.6,
notebookId: options?.notebookId // NEW: Pass notebook filter
})
return {
results,
query,
totalResults: results.length
}
} catch (error) {
console.error('Error in semantic search action:', error)
throw error
}
}
/**
* Index a note for semantic search (generate embedding)
*/
export async function indexNote(noteId: string): Promise<void> {
try {
await semanticSearchService.indexNote(noteId)
} catch (error) {
console.error('Error indexing note:', error)
throw error
}
}
/**
* Batch index notes (for initial setup)
*/
export async function batchIndexNotes(noteIds: string[]): Promise<void> {
try {
await semanticSearchService.indexBatchNotes(noteIds)
} catch (error) {
console.error('Error batch indexing notes:', error)
throw error
}
}

View File

@@ -0,0 +1,128 @@
'use server'
import { auth } from '@/auth'
import { titleSuggestionService } from '@/lib/ai/services/title-suggestion.service'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export interface GenerateTitlesResponse {
suggestions: Array<{
title: string
confidence: number
reasoning?: string
}>
noteId: string
}
/**
* Generate title suggestions for a note
* Triggered when note reaches 50+ words without a title
*/
export async function generateTitleSuggestions(noteId: string): Promise<GenerateTitlesResponse> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Fetch note content
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { id: true, content: true, userId: true }
})
if (!note) {
throw new Error('Note not found')
}
if (note.userId !== session.user.id) {
throw new Error('Forbidden')
}
if (!note.content || note.content.trim().length === 0) {
throw new Error('Note content is empty')
}
// Generate suggestions
const suggestions = await titleSuggestionService.generateSuggestions(note.content)
return {
suggestions,
noteId
}
} catch (error) {
console.error('Error generating title suggestions:', error)
throw error
}
}
/**
* Apply selected title to note
*/
export async function applyTitleSuggestion(
noteId: string,
selectedTitle: string
): Promise<void> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Update note with selected title
await prisma.note.update({
where: {
id: noteId,
userId: session.user.id
},
data: {
title: selectedTitle,
autoGenerated: true,
lastAiAnalysis: new Date()
}
})
revalidatePath('/')
revalidatePath(`/note/${noteId}`)
} catch (error) {
console.error('Error applying title suggestion:', error)
throw error
}
}
/**
* Record user feedback on title suggestions
* (Phase 3 - for improving future suggestions)
*/
export async function recordTitleFeedback(
noteId: string,
selectedTitle: string,
allSuggestions: Array<{ title: string; confidence: number }>
): Promise<void> {
const session = await auth()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
try {
// Save to AiFeedback table for learning
await prisma.aiFeedback.create({
data: {
noteId,
userId: session.user.id,
feedbackType: 'thumbs_up', // User chose one of our suggestions
feature: 'title_suggestion',
originalContent: JSON.stringify(allSuggestions),
correctedContent: selectedTitle,
metadata: JSON.stringify({
timestamp: new Date().toISOString(),
provider: 'auto' // Will be dynamic based on user settings
})
}
})
} catch (error) {
console.error('Error recording title feedback:', error)
// Don't throw - feedback is optional
}
}

View File

@@ -0,0 +1,83 @@
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath, revalidateTag } from 'next/cache'
export type UserSettingsData = {
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
}
/**
* Update user settings (theme, etc.)
*/
export async function updateUserSettings(settings: UserSettingsData) {
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
})
revalidatePath('/', 'layout')
revalidateTag('user-settings')
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 (Cached)
*/
import { unstable_cache } from 'next/cache'
// Internal cached function
const getCachedUserSettings = unstable_cache(
async (userId: string) => {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { theme: true }
})
return {
theme: (user?.theme || 'light') as 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
}
} catch (error) {
console.error('Error getting user settings:', error)
return {
theme: 'light' as const
}
}
},
['user-settings'],
{ tags: ['user-settings'] }
)
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
}
}
return getCachedUserSettings(id)
}

View File

@@ -0,0 +1,100 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { validateEmbedding } from '@/lib/utils'
/**
* Admin endpoint to validate all embeddings in the database
* Returns a list of notes with invalid embeddings
*/
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is admin
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
})
if (!user || user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden - Admin only' }, { status: 403 })
}
// Fetch all notes with embeddings
const allNotes = await prisma.note.findMany({
select: {
id: true,
title: true,
embedding: true
}
})
const invalidNotes: Array<{
id: string
title: string
issues: string[]
}> = []
let validCount = 0
let missingCount = 0
let invalidCount = 0
for (const note of allNotes) {
// Check if embedding is missing
if (!note.embedding) {
missingCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: ['Missing embedding']
})
continue
}
// Parse and validate embedding
try {
const embedding = JSON.parse(note.embedding)
const validation = validateEmbedding(embedding)
if (!validation.valid) {
invalidCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: validation.issues
})
} else {
validCount++
}
} catch (error) {
invalidCount++
invalidNotes.push({
id: note.id,
title: note.title || 'Untitled',
issues: [`Failed to parse embedding: ${error}`]
})
}
}
return NextResponse.json({
success: true,
summary: {
total: allNotes.length,
valid: validCount,
missing: missingCount,
invalid: invalidCount
},
invalidNotes
})
} catch (error) {
console.error('[EMBEDDING_VALIDATION] Error:', error)
return NextResponse.json(
{ success: false, error: String(error) },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { LABEL_COLORS } from '@/lib/types';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
const labels = await prisma.label.findMany();
const colors = Object.keys(LABEL_COLORS).filter(c => c !== 'gray'); // Exclude gray to force colors
const updates = labels.map((label: any) => {
const randomColor = colors[Math.floor(Math.random() * colors.length)];
return prisma.label.update({
where: { id: label.id },
data: { color: randomColor }
});
});
await prisma.$transaction(updates);
return NextResponse.json({
success: true,
updated: updates.length,
message: "All labels have been assigned a random non-gray color."
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export const dynamic = 'force-dynamic';
export async function GET() {
try {
// 1. Get all notes
const notes = await prisma.note.findMany({
select: { labels: true }
});
// 2. Extract all unique labels from JSON
const uniqueLabels = new Set<string>();
notes.forEach((note: any) => {
if (note.labels) {
try {
const parsed = JSON.parse(note.labels);
if (Array.isArray(parsed)) {
parsed.forEach((l: string) => uniqueLabels.add(l));
}
} catch (e) {
// ignore error
}
}
});
// 3. Get existing labels in DB
const existingDbLabels = await prisma.label.findMany();
const existingNames = new Set(existingDbLabels.map((l: any) => l.name));
// 4. Create missing labels
const created = [];
for (const name of uniqueLabels) {
if (!existingNames.has(name)) {
const newLabel = await prisma.label.create({
data: {
name,
color: 'gray' // Default color
}
});
created.push(newLabel);
}
}
return NextResponse.json({
success: true,
foundInNotes: uniqueLabels.size,
alreadyInDb: existingNames.size,
created: created.length,
createdLabels: created
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View File

@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services'
/**
* POST /api/ai/auto-labels - Suggest new labels for a notebook
*/
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await request.json()
const { notebookId, language = 'en' } = body
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
{ success: false, error: 'Missing required field: notebookId' },
{ status: 400 }
)
}
// Check if notebook belongs to user
const { prisma } = await import('@/lib/prisma')
const notebook = await prisma.notebook.findFirst({
where: {
id: notebookId,
userId: session.user.id,
},
})
if (!notebook) {
return NextResponse.json(
{ success: false, error: 'Notebook not found' },
{ status: 404 }
)
}
// Get label suggestions
const suggestions = await autoLabelCreationService.suggestLabels(
notebookId,
session.user.id,
language
)
if (!suggestions) {
return NextResponse.json({
success: true,
data: null,
message: 'No suggestions available (notebook may have fewer than 15 notes)',
})
}
return NextResponse.json({
success: true,
data: suggestions,
})
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to get label suggestions',
},
{ status: 500 }
)
}
}
/**
* PUT /api/ai/auto-labels - Create suggested labels
*/
export async function PUT(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await request.json()
const { suggestions, selectedLabels } = body
if (!suggestions || !Array.isArray(selectedLabels)) {
return NextResponse.json(
{ success: false, error: 'Missing required fields: suggestions, selectedLabels' },
{ status: 400 }
)
}
// Create labels
const createdCount = await autoLabelCreationService.createLabels(
suggestions.notebookId,
session.user.id,
suggestions,
selectedLabels
)
return NextResponse.json({
success: true,
data: {
createdCount,
},
})
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create labels',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,101 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services'
/**
* POST /api/ai/batch-organize - Create organization plan for notes in Inbox
*/
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
// Get language from request headers or body
let language = 'en'
try {
const body = await request.json()
if (body.language) {
language = body.language
}
} catch (e) {
// If no body or invalid json, check headers
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
language = acceptLanguage.split(',')[0].split('-')[0]
}
}
// Create organization plan
const plan = await batchOrganizationService.createOrganizationPlan(
session.user.id,
language
)
return NextResponse.json({
success: true,
data: plan,
})
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create organization plan',
},
{ status: 500 }
)
}
}
/**
* PUT /api/ai/batch-organize - Apply organization plan
*/
export async function PUT(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await request.json()
const { plan, selectedNoteIds } = body
if (!plan || !Array.isArray(selectedNoteIds)) {
return NextResponse.json(
{ success: false, error: 'Missing required fields: plan, selectedNoteIds' },
{ status: 400 }
)
}
// Apply organization plan
const movedCount = await batchOrganizationService.applyOrganizationPlan(
session.user.id,
plan,
selectedNoteIds
)
return NextResponse.json({
success: true,
data: {
movedCount,
},
})
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to apply organization plan',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSystemConfig } from '@/lib/config'
export async function GET(request: NextRequest) {
try {
const config = await getSystemConfig()
return NextResponse.json({
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
OPENAI_API_KEY: config.OPENAI_API_KEY ? '***configured***' : '',
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? '***configured***' : '',
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || '',
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set'
})
} catch (error: any) {
return NextResponse.json(
{
error: error.message || 'Failed to fetch config'
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
/**
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
* Fetch all connections for a specific note
*/
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Get query parameters
const { searchParams } = new URL(req.url)
const noteId = searchParams.get('noteId')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
// Validate noteId
if (!noteId) {
return NextResponse.json(
{ error: 'noteId parameter is required' },
{ status: 400 }
)
}
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 50) {
return NextResponse.json(
{ error: 'Invalid pagination parameters. page >= 1, limit between 1 and 50' },
{ status: 400 }
)
}
// Get all connections for the note
const allConnections = await memoryEchoService.getConnectionsForNote(noteId, session.user.id)
// Calculate pagination
const total = allConnections.length
const startIndex = (page - 1) * limit
const endIndex = startIndex + limit
const paginatedConnections = allConnections.slice(startIndex, endIndex)
// Format connections for response
const connections = paginatedConnections.map(conn => {
// Determine which note is the "other" note (not the target note)
const isNote1Target = conn.note1.id === noteId
const otherNote = isNote1Target ? conn.note2 : conn.note1
return {
noteId: otherNote.id,
title: otherNote.title,
content: otherNote.content,
createdAt: otherNote.createdAt,
similarity: conn.similarityScore,
daysApart: conn.daysApart
}
})
return NextResponse.json({
connections,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
hasNext: endIndex < total,
hasPrev: page > 1
}
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch connections' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
/**
* POST /api/ai/echo/dismiss
* Dismiss a connection for a specific note
* Body: { noteId, connectedNoteId }
*/
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await req.json()
const { noteId, connectedNoteId } = body
if (!noteId || !connectedNoteId) {
return NextResponse.json(
{ error: 'noteId and connectedNoteId are required' },
{ status: 400 }
)
}
// Find and mark matching insights as dismissed
// We need to find insights where (note1Id = noteId AND note2Id = connectedNoteId) OR (note1Id = connectedNoteId AND note2Id = noteId)
await prisma.memoryEchoInsight.updateMany({
where: {
userId: session.user.id,
OR: [
{
note1Id: noteId,
note2Id: connectedNoteId
},
{
note1Id: connectedNoteId,
note2Id: noteId
}
]
},
data: {
dismissed: true
}
})
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to dismiss connection' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import prisma from '@/lib/prisma'
/**
* POST /api/ai/echo/fusion
* Generate intelligent fusion of multiple notes
*/
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await req.json()
const { noteIds, prompt } = body
if (!noteIds || !Array.isArray(noteIds) || noteIds.length < 2) {
return NextResponse.json(
{ error: 'At least 2 note IDs are required' },
{ status: 400 }
)
}
// Fetch the notes
const notes = await prisma.note.findMany({
where: {
id: { in: noteIds },
userId: session.user.id
},
select: {
id: true,
title: true,
content: true,
createdAt: true
}
})
if (notes.length !== noteIds.length) {
return NextResponse.json(
{ error: 'Some notes not found or access denied' },
{ status: 404 }
)
}
// Get AI provider
const config = await prisma.systemConfig.findFirst()
const provider = getAIProvider(config || undefined)
// Build fusion prompt
const notesDescriptions = notes.map((note, index) => {
return `Note ${index + 1}: "${note.title || 'Untitled'}"
${note.content}`
}).join('\n\n')
const fusionPrompt = `You are an expert at synthesizing and merging information from multiple sources.
TASK: Create a unified, well-structured note by intelligently combining the following notes.
${prompt ? `ADDITIONAL INSTRUCTIONS: ${prompt}\n` : ''}
NOTES TO MERGE:
${notesDescriptions}
REQUIREMENTS:
1. Create a clear, descriptive title that captures the essence of all notes
2. Merge and consolidate related information
3. Remove duplicates while preserving unique details from each note
4. Organize the content logically (use headers, bullet points, etc.)
5. Maintain the important details and context from all notes
6. Keep the tone and style consistent
7. Use markdown formatting for better readability
Output format:
# [Fused Title]
[Merged and organized content...]
Begin:`
try {
const fusedContent = await provider.generateText(fusionPrompt)
return NextResponse.json({
fusedNote: fusedContent,
notesCount: notes.length
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate fusion' },
{ status: 500 }
)
}
} catch (error) {
return NextResponse.json(
{ error: 'Failed to process fusion request' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
/**
* GET /api/ai/echo
* Fetch next Memory Echo insight for current user
*/
export async function GET(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// Get next insight (respects frequency limits)
const insight = await memoryEchoService.getNextInsight(session.user.id)
if (!insight) {
return NextResponse.json(
{
insight: null,
message: 'No new insights available at the moment. Memory Echo will notify you when we discover connections between your notes.'
}
)
}
return NextResponse.json({ insight })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch Memory Echo insight' },
{ status: 500 }
)
}
}
/**
* POST /api/ai/echo
* Submit feedback or mark as viewed
*/
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await req.json()
const { action, insightId, feedback } = body
if (action === 'view') {
// Mark insight as viewed
await memoryEchoService.markAsViewed(insightId)
return NextResponse.json({ success: true })
} else if (action === 'feedback') {
// Submit feedback (thumbs_up or thumbs_down)
if (!feedback || !['thumbs_up', 'thumbs_down'].includes(feedback)) {
return NextResponse.json(
{ error: 'Invalid feedback. Must be thumbs_up or thumbs_down' },
{ status: 400 }
)
}
await memoryEchoService.submitFeedback(insightId, feedback)
return NextResponse.json({ success: true })
} else {
return NextResponse.json(
{ error: 'Invalid action. Must be "view" or "feedback"' },
{ status: 400 }
)
}
} catch (error) {
return NextResponse.json(
{ error: 'Failed to process request' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSystemConfig } from '@/lib/config'
// Modèles populaires pour chaque provider (2025)
const PROVIDER_MODELS = {
ollama: {
tags: [
'llama3:latest',
'llama3.2:latest',
'granite4:latest',
'mistral:latest',
'mixtral:latest',
'phi3:latest',
'gemma2:latest',
'qwen2:latest'
],
embeddings: [
'embeddinggemma:latest',
'mxbai-embed-large:latest',
'nomic-embed-text:latest'
]
},
openai: {
tags: [
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'gpt-4',
'gpt-3.5-turbo'
],
embeddings: [
'text-embedding-3-small',
'text-embedding-3-large',
'text-embedding-ada-002'
]
},
custom: {
tags: [], // Will be loaded dynamically
embeddings: [] // Will be loaded dynamically
}
}
export async function GET(request: NextRequest) {
try {
const config = await getSystemConfig()
const provider = (config.AI_PROVIDER || 'ollama').toLowerCase()
let models = PROVIDER_MODELS[provider as keyof typeof PROVIDER_MODELS] || { tags: [], embeddings: [] }
// Pour Ollama, essayer de récupérer la liste réelle depuis l'API locale
if (provider === 'ollama') {
try {
const ollamaBaseUrl = config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434'
const response = await fetch(`${ollamaBaseUrl}/api/tags`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
if (response.ok) {
const data = await response.json()
const allModels = data.models || []
// Séparer les modèles de tags et d'embeddings
const tagModels = allModels
.filter((m: any) => !m.name.includes('embed') && !m.name.includes('Embedding'))
.map((m: any) => m.name)
.slice(0, 20) // Limiter à 20 modèles
const embeddingModels = allModels
.filter((m: any) => m.name.includes('embed') || m.name.includes('Embedding'))
.map((m: any) => m.name)
models = {
tags: tagModels.length > 0 ? tagModels : models.tags,
embeddings: embeddingModels.length > 0 ? embeddingModels : models.embeddings
}
}
} catch (error) {
// Garder les modèles par défaut
}
}
return NextResponse.json({
provider,
models: models || { tags: [], embeddings: [] }
})
} catch (error: any) {
return NextResponse.json(
{
error: error.message || 'Failed to fetch models',
models: { tags: [], embeddings: [] }
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services'
/**
* POST /api/ai/notebook-summary - Generate summary for a notebook
*/
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
const body = await request.json()
const { notebookId, language = 'en' } = body
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
{ success: false, error: 'Missing required field: notebookId' },
{ status: 400 }
)
}
// Check if notebook belongs to user
const { prisma } = await import('@/lib/prisma')
const notebook = await prisma.notebook.findFirst({
where: {
id: notebookId,
userId: session.user.id,
},
})
if (!notebook) {
return NextResponse.json(
{ success: false, error: 'Notebook not found' },
{ status: 404 }
)
}
// Generate summary
const summary = await notebookSummaryService.generateSummary(
notebookId,
session.user.id,
language
)
if (!summary) {
return NextResponse.json({
success: true,
data: null,
message: 'No summary available (notebook may be empty)',
})
}
return NextResponse.json({
success: true,
data: summary,
})
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to generate notebook summary',
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { text, option } = await request.json()
// Validation
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
// Map option to refactor mode
const modeMap: Record<string, 'clarify' | 'shorten' | 'improveStyle'> = {
'clarify': 'clarify',
'shorten': 'shorten',
'improve': 'improveStyle'
}
const mode = modeMap[option]
if (!mode) {
return NextResponse.json(
{ error: 'Invalid option. Use: clarify, shorten, or improve' },
{ status: 400 }
)
}
// Validate word count
const validation = paragraphRefactorService.validateWordCount(text)
if (!validation.valid) {
return NextResponse.json({ error: validation.error }, { status: 400 })
}
// Use the ParagraphRefactorService
const result = await paragraphRefactorService.refactor(text, mode)
return NextResponse.json({
originalText: result.original,
reformulatedText: result.refactored,
option: option,
language: result.language,
wordCountChange: result.wordCountChange
})
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Failed to reformulate text' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
export async function POST(req: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { noteContent, language = 'en' } = body
if (!noteContent || typeof noteContent !== 'string') {
return NextResponse.json({ error: 'noteContent is required' }, { status: 400 })
}
// Minimum content length for suggestion (20 words as per specs)
const wordCount = noteContent.trim().split(/\s+/).length
if (wordCount < 20) {
return NextResponse.json({
suggestion: null,
reason: 'content_too_short',
message: 'Note content too short for meaningful suggestion'
})
}
// Get suggestion from AI service
const suggestedNotebook = await notebookSuggestionService.suggestNotebook(
noteContent,
session.user.id,
language
)
return NextResponse.json({
suggestion: suggestedNotebook,
confidence: suggestedNotebook ? 0.8 : 0 // Placeholder confidence score
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to generate suggestion' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/auth';
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service';
import { getAIProvider } from '@/lib/ai/factory';
import { getSystemConfig } from '@/lib/config';
import { z } from 'zod';
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
notebookId: z.string().optional(),
language: z.string().default('en'),
});
export async function POST(req: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { content, notebookId, language } = requestSchema.parse(body);
// If notebookId is provided, use contextual suggestions (IA2)
if (notebookId) {
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
session.user.id,
language
);
// Convert label → tag to match TagSuggestion interface
const convertedTags = suggestions.map(s => ({
tag: s.label, // Convert label to tag
confidence: s.confidence,
// Keep additional properties for client-side use
...(s.reasoning && { reasoning: s.reasoning }),
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
}));
return NextResponse.json({ tags: convertedTags });
}
// Otherwise, use legacy auto-tagging (generates new tags)
const config = await getSystemConfig();
const provider = getAIProvider(config);
const tags = await provider.generateTags(content);
return NextResponse.json({ tags });
} catch (error: any) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 });
}
return NextResponse.json(
{ error: error.message || 'Erreur lors de la génération des tags' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'
import { getEmbeddingsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
function getProviderDetails(config: Record<string, string>, providerType: string) {
const provider = providerType.toLowerCase()
switch (provider) {
case 'ollama':
return {
provider: 'Ollama',
baseUrl: config.OLLAMA_BASE_URL || 'http://localhost:11434',
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
}
case 'openai':
return {
provider: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
}
case 'custom':
return {
provider: 'Custom OpenAI',
baseUrl: config.CUSTOM_OPENAI_BASE_URL || 'Not configured',
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
}
default:
return {
provider: provider,
baseUrl: 'unknown',
model: config.AI_MODEL_EMBEDDING || 'unknown'
}
}
}
export async function POST(request: NextRequest) {
try {
const config = await getSystemConfig()
const provider = getEmbeddingsProvider(config)
const testText = 'test'
const startTime = Date.now()
const embeddings = await provider.getEmbeddings(testText)
const endTime = Date.now()
if (!embeddings || embeddings.length === 0) {
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json(
{
success: false,
error: 'No embeddings returned',
provider: providerType,
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
details
},
{ status: 500 }
)
}
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json({
success: true,
provider: providerType,
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
embeddingLength: embeddings.length,
firstValues: embeddings.slice(0, 5),
responseTime: endTime - startTime,
details
})
} catch (error: any) {
const config = await getSystemConfig()
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json(
{
success: false,
error: error.message || 'Unknown error',
provider: providerType,
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest',
details,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export async function POST(request: NextRequest) {
try {
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const testContent = "This is a test note about artificial intelligence and machine learning. It contains keywords like AI, ML, neural networks, and deep learning."
const startTime = Date.now()
const tags = await provider.generateTags(testContent)
const endTime = Date.now()
if (!tags || tags.length === 0) {
return NextResponse.json(
{
success: false,
error: 'No tags generated',
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest'
},
{ status: 500 }
)
}
return NextResponse.json({
success: true,
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest',
tags: tags,
responseTime: endTime - startTime
})
} catch (error: any) {
const config = await getSystemConfig()
return NextResponse.json(
{
success: false,
error: error.message || 'Unknown error',
provider: config.AI_PROVIDER_TAGS || 'ollama',
model: config.AI_MODEL_TAGS || 'granite4:latest',
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'
import { getTagsProvider, getEmbeddingsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
function getProviderDetails(config: Record<string, string>, providerType: string) {
const provider = providerType.toLowerCase()
switch (provider) {
case 'ollama':
return {
provider: 'Ollama',
baseUrl: config.OLLAMA_BASE_URL || process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
model: config.AI_MODEL_EMBEDDING || 'embeddinggemma:latest'
}
case 'openai':
return {
provider: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
}
case 'custom':
return {
provider: 'Custom OpenAI',
baseUrl: config.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || 'Not configured',
model: config.AI_MODEL_EMBEDDING || 'text-embedding-3-small'
}
default:
return {
provider: provider,
baseUrl: 'unknown',
model: config.AI_MODEL_EMBEDDING || 'unknown'
}
}
}
export async function GET(request: NextRequest) {
try {
const config = await getSystemConfig()
const tagsProvider = getTagsProvider(config)
const embeddingsProvider = getEmbeddingsProvider(config)
const testText = 'test'
// Test embeddings provider
const embeddings = await embeddingsProvider.getEmbeddings(testText)
if (!embeddings || embeddings.length === 0) {
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json(
{
success: false,
tagsProvider: config.AI_PROVIDER_TAGS || 'ollama',
embeddingsProvider: providerType,
error: 'No embeddings returned',
details
},
{ status: 500 }
)
}
const tagsProviderType = config.AI_PROVIDER_TAGS || 'ollama'
const embeddingsProviderType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, embeddingsProviderType)
return NextResponse.json({
success: true,
tagsProvider: tagsProviderType,
embeddingsProvider: embeddingsProviderType,
embeddingLength: embeddings.length,
firstValues: embeddings.slice(0, 5),
details
})
} catch (error: any) {
const config = await getSystemConfig()
const providerType = config.AI_PROVIDER_EMBEDDING || 'ollama'
const details = getProviderDetails(config, providerType)
return NextResponse.json(
{
success: false,
error: error.message || 'Unknown error',
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
details
},
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { z } from 'zod'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
})
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { content } = requestSchema.parse(body)
// Vérifier qu'il y a au moins 10 mots
const wordCount = content.split(/\s+/).length
if (wordCount < 10) {
return NextResponse.json(
{ error: 'Le contenu doit avoir au moins 10 mots' },
{ status: 400 }
)
}
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Détecter la langue du contenu (simple détection basée sur les caractères)
const hasNonLatinChars = /[\u0400-\u04FF\u0600-\u06FF\u4E00-\u9FFF\u0E00-\u0E7F]/.test(content)
const isPersian = /[\u0600-\u06FF]/.test(content)
const isChinese = /[\u4E00-\u9FFF]/.test(content)
const isRussian = /[\u0400-\u04FF]/.test(content)
const isArabic = /[\u0600-\u06FF]/.test(content)
// Déterminer la langue du prompt système
let promptLanguage = 'en'
let responseLanguage = 'English'
if (isPersian) {
promptLanguage = 'fa' // Persan
responseLanguage = 'Persian'
} else if (isChinese) {
promptLanguage = 'zh' // Chinois
responseLanguage = 'Chinese'
} else if (isRussian) {
promptLanguage = 'ru' // Russe
responseLanguage = 'Russian'
} else if (isArabic) {
promptLanguage = 'ar' // Arabe
responseLanguage = 'Arabic'
}
// Générer des titres appropriés basés sur le contenu
const titlePrompt = promptLanguage === 'en'
? `You are a title generator. Generate 3 concise, descriptive titles for the following content.
IMPORTANT INSTRUCTIONS:
- Use ONLY the content provided below between the CONTENT_START and CONTENT_END markers
- Do NOT use any external knowledge or training data
- Focus on the main topics and themes in THIS SPECIFIC content
- Be specific to what is actually discussed
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
: `Tu es un générateur de titres. Génère 3 titres concis et descriptifs pour le contenu suivant en ${responseLanguage}.
INSTRUCTIONS IMPORTANTES :
- Utilise SEULEMENT le contenu fourni entre les marqueurs CONTENT_START et CONTENT_END
- N'utilise AUCUNE connaissance externe ou données d'entraînement
- Concentre-toi sur les sujets principaux et thèmes de CE CONTENU SPÉCIFIQUE
- Sois spécifique à ce qui est réellement discuté
CONTENT_START: ${content.substring(0, 500)} CONTENT_END
Réponds SEULEMENT avec un tableau JSON: [{"title": "titre1", "confidence": 0.95}, {"title": "titre2", "confidence": 0.85}, {"title": "titre3", "confidence": 0.75}]`
const titles = await provider.generateTitles(titlePrompt)
// Créer les suggestions
const suggestions = titles.map((t: any) => ({
title: t.title,
confidence: Math.round(t.confidence * 100),
reasoning: `Basé sur le contenu`
}))
return NextResponse.json({ suggestions })
} catch (error: any) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.issues }, { status: 400 })
}
return NextResponse.json(
{ error: error.message || 'Erreur lors de la génération des titres' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { text } = await request.json()
// Validation
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
// Validate word count
const wordCount = text.split(/\s+/).length
if (wordCount < 10) {
return NextResponse.json(
{ error: 'Text must have at least 10 words to transform' },
{ status: 400 }
)
}
if (wordCount > 500) {
return NextResponse.json(
{ error: 'Text must have maximum 500 words to transform' },
{ status: 400 }
)
}
const config = await getSystemConfig()
const provider = getAIProvider(config)
// Detect language from text
const hasFrench = /[àâäéèêëïîôùûüÿç]/i.test(text)
const responseLanguage = hasFrench ? 'French' : 'English'
// Build prompt to transform text to Markdown
const prompt = hasFrench
? `Tu es un expert en Markdown. Transforme ce texte ${responseLanguage} en Markdown bien formaté.
IMPORTANT :
- Ajoute des titres avec ## pour les sections principales
- Utilise des listes à puces (-) ou numérotées (1.) quand approprié
- Ajoute de l'emphase (gras **texte**, italique *texte*) pour les mots clés
- Utilise des blocs de code pour le code ou les commandes
- Présente l'information de manière claire et structurée
- GARDE le même sens et le contenu, seul le format change
Texte à transformer :
${text}
Réponds SEULEMENT avec le texte transformé en Markdown, sans explications.`
: `You are a Markdown expert. Transform this ${responseLanguage} text into well-formatted Markdown.
IMPORTANT:
- Add headings with ## for main sections
- Use bullet lists (-) or numbered lists (1.) when appropriate
- Add emphasis (bold **text**, italic *text*) for key terms
- Use code blocks for code or commands
- Present information clearly and structured
- KEEP the same meaning and content, only change the format
Text to transform:
${text}
Respond ONLY with the transformed Markdown text, no explanations.`
const transformedText = await provider.generateText(prompt)
return NextResponse.json({
originalText: text,
transformedText: transformedText,
language: responseLanguage
})
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Failed to transform text to Markdown' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,2 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export const dynamic = 'force-dynamic'; // No caching
export async function POST(request: Request) {
try {
const now = new Date();
// 1. Find all due reminders that haven't been processed
const dueNotes = await prisma.note.findMany({
where: {
reminder: {
lte: now, // Less than or equal to now
},
isReminderDone: false,
isArchived: false, // Optional: exclude archived notes
},
select: {
id: true,
title: true,
content: true,
reminder: true,
// Add other fields useful for notification
},
});
if (dueNotes.length === 0) {
return NextResponse.json({
success: true,
count: 0,
message: 'No due reminders found'
});
}
// 2. Mark them as done (Atomic operation logic would be better but simple batch update is fine here)
const noteIds = dueNotes.map((n: any) => n.id);
await prisma.note.updateMany({
where: {
id: { in: noteIds }
},
data: {
isReminderDone: true
}
});
// 3. Return the notes to N8N so it can send emails/messages
return NextResponse.json({
success: true,
count: dueNotes.length,
reminders: dueNotes
});
} catch (error) {
console.error('Error processing cron reminders:', error);
return NextResponse.json(
{ success: false, error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { getSystemConfig } from '@/lib/config';
/**
* Debug endpoint to check AI configuration
* This helps verify that OpenAI is properly configured
*/
export async function GET() {
try {
const config = await getSystemConfig();
// Return only AI-related config for debugging
const aiConfig = {
AI_PROVIDER_TAGS: config.AI_PROVIDER_TAGS || 'not set',
AI_PROVIDER_EMBEDDING: config.AI_PROVIDER_EMBEDDING || 'not set',
AI_MODEL_TAGS: config.AI_MODEL_TAGS || 'not set',
AI_MODEL_EMBEDDING: config.AI_MODEL_EMBEDDING || 'not set',
OPENAI_API_KEY: config.OPENAI_API_KEY ? 'set (hidden)' : 'not set',
OLLAMA_BASE_URL: config.OLLAMA_BASE_URL || 'not set',
OLLAMA_MODEL: config.OLLAMA_MODEL || 'not set',
CUSTOM_OPENAI_BASE_URL: config.CUSTOM_OPENAI_BASE_URL || 'not set',
CUSTOM_OPENAI_API_KEY: config.CUSTOM_OPENAI_API_KEY ? 'set (hidden)' : 'not set',
};
return NextResponse.json(aiConfig);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to get config', details: error },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,118 @@
import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
function getHashColor(name: string): string {
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
let hash = 0
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}
export async function POST() {
try {
const result = { created: 0, deleted: 0, missing: [] as string[] }
// Get ALL users
const users = await prisma.user.findMany({
select: { id: true, email: true }
})
for (const user of users) {
const userId = user.id
// 1. Get all labels from notes
const allNotes = await prisma.note.findMany({
where: { userId },
select: { labels: true }
})
const labelsInNotes = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = JSON.parse(note.labels)
if (Array.isArray(parsed)) {
parsed.forEach(l => {
if (l && l.trim()) labelsInNotes.add(l.trim())
})
}
} catch (e) {}
}
})
// 2. Get existing Label records
const existingLabels = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true }
})
const existingLabelMap = new Map<string, any>()
existingLabels.forEach(label => {
existingLabelMap.set(label.name.toLowerCase(), label)
})
// 3. Create missing Label records
for (const labelName of labelsInNotes) {
if (!existingLabelMap.has(labelName.toLowerCase())) {
try {
await prisma.label.create({
data: {
userId,
name: labelName,
color: getHashColor(labelName)
}
})
result.created++
} catch (e: any) {
console.error(`[FIX] ✗ Failed to create "${labelName}":`, e.message, e.code)
result.missing.push(labelName)
}
}
}
// 4. Delete orphan Label records
const usedLabelsSet = new Set<string>()
allNotes.forEach(note => {
if (note.labels) {
try {
const parsed: string[] = JSON.parse(note.labels)
if (Array.isArray(parsed)) {
parsed.forEach(l => usedLabelsSet.add(l.toLowerCase()))
}
} catch (e) {}
}
})
for (const label of existingLabels) {
if (!usedLabelsSet.has(label.name.toLowerCase())) {
try {
await prisma.label.delete({
where: { id: label.id }
})
result.deleted++
} catch (e) {}
}
}
}
revalidatePath('/')
return NextResponse.json({
success: true,
...result,
message: `Created ${result.created} labels, deleted ${result.deleted} orphans`
})
} catch (error) {
console.error('[FIX] Error:', error)
return NextResponse.json(
{ success: false, error: String(error) },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,247 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { auth } from '@/auth'
// GET /api/labels/[id] - Get a specific label
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const label = await prisma.label.findUnique({
where: { id },
include: {
notebook: {
select: { id: true, name: true }
}
}
})
if (!label) {
return NextResponse.json(
{ success: false, error: 'Label not found' },
{ status: 404 }
)
}
// Verify ownership
if (label.notebookId) {
const notebook = await prisma.notebook.findUnique({
where: { id: label.notebookId },
select: { userId: true }
})
if (notebook?.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else if (label.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return NextResponse.json({
success: true,
data: label
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to fetch label' },
{ status: 500 }
)
}
}
// PUT /api/labels/[id] - Update a label
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const body = await request.json()
const { name, color } = body
// Get the current label first
const currentLabel = await prisma.label.findUnique({
where: { id },
include: {
notebook: {
select: { id: true, userId: true }
}
}
})
if (!currentLabel) {
return NextResponse.json(
{ success: false, error: 'Label not found' },
{ status: 404 }
)
}
// Verify ownership
if (currentLabel.notebookId) {
if (currentLabel.notebook?.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else if (currentLabel.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const newName = name ? name.trim() : currentLabel.name
// For backward compatibility, update old label field in notes if renaming
if (name && name.trim() !== currentLabel.name && currentLabel.userId) {
const allNotes = await prisma.note.findMany({
where: {
userId: currentLabel.userId,
labels: { not: null }
},
select: { id: true, labels: true }
})
for (const note of allNotes) {
if (note.labels) {
try {
const noteLabels: string[] = JSON.parse(note.labels)
const updatedLabels = noteLabels.map(l =>
l.toLowerCase() === currentLabel.name.toLowerCase() ? newName : l
)
if (JSON.stringify(updatedLabels) !== JSON.stringify(noteLabels)) {
await prisma.note.update({
where: { id: note.id },
data: {
labels: JSON.stringify(updatedLabels)
}
})
}
} catch (e) {
}
}
}
}
// Now update the label record
const label = await prisma.label.update({
where: { id },
data: {
...(name && { name: newName }),
...(color && { color })
}
})
// Revalidate to refresh UI
revalidatePath('/')
return NextResponse.json({
success: true,
data: label
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to update label' },
{ status: 500 }
)
}
}
// DELETE /api/labels/[id] - Delete a label
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
// First, get the label to know its name and userId
const label = await prisma.label.findUnique({
where: { id },
include: {
notebook: {
select: { id: true, userId: true }
}
}
})
if (!label) {
return NextResponse.json(
{ success: false, error: 'Label not found' },
{ status: 404 }
)
}
// Verify ownership
if (label.notebookId) {
if (label.notebook?.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else if (label.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// For backward compatibility, remove from old label field in notes
if (label.userId) {
const allNotes = await prisma.note.findMany({
where: {
userId: label.userId,
labels: { not: null }
},
select: { id: true, labels: true }
})
for (const note of allNotes) {
if (note.labels) {
try {
const noteLabels: string[] = JSON.parse(note.labels)
const filteredLabels = noteLabels.filter(
l => l.toLowerCase() !== label.name.toLowerCase()
)
if (filteredLabels.length !== noteLabels.length) {
await prisma.note.update({
where: { id: note.id },
data: {
labels: filteredLabels.length > 0 ? JSON.stringify(filteredLabels) : null
}
})
}
} catch (e) {
}
}
}
}
// Now delete the label record
await prisma.label.delete({
where: { id }
})
// Revalidate to refresh UI
revalidatePath('/')
return NextResponse.json({
success: true,
message: `Label "${label.name}" deleted successfully`
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to delete label' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
const COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'pink', 'gray'];
// GET /api/labels - Get all labels (supports optional notebookId filter)
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const searchParams = request.nextUrl.searchParams
const notebookId = searchParams.get('notebookId')
// Build where clause
const where: any = {}
if (notebookId === 'null' || notebookId === '') {
// Get labels without a notebook (backward compatibility)
where.notebookId = null
} else if (notebookId) {
// Get labels for a specific notebook
where.notebookId = notebookId
} else {
// Get all labels for the user (both old and new system)
where.OR = [
{ notebookId: { not: null } },
{ userId: session.user.id }
]
}
const labels = await prisma.label.findMany({
where,
include: {
notebook: {
select: { id: true, name: true }
}
},
orderBy: { name: 'asc' }
})
return NextResponse.json({
success: true,
data: labels
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to fetch labels' },
{ status: 500 }
)
}
}
// POST /api/labels - Create a new label
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await request.json()
const { name, color, notebookId } = body
if (!name || typeof name !== 'string') {
return NextResponse.json(
{ success: false, error: 'Label name is required' },
{ status: 400 }
)
}
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json(
{ success: false, error: 'notebookId is required' },
{ status: 400 }
)
}
// Verify notebook ownership
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
select: { userId: true }
})
if (!notebook || notebook.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Notebook not found or unauthorized' },
{ status: 403 }
)
}
// Check if label already exists in this notebook
const existing = await prisma.label.findFirst({
where: {
name: name.trim(),
notebookId: notebookId
}
})
if (existing) {
return NextResponse.json(
{ success: false, error: 'Label already exists in this notebook' },
{ status: 409 }
)
}
const label = await prisma.label.create({
data: {
name: name.trim(),
color: color || COLORS[Math.floor(Math.random() * COLORS.length)],
notebookId: notebookId,
userId: session.user.id // Keep for backward compatibility
}
})
return NextResponse.json({
success: true,
data: label
}, { status: 201 })
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to create label' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
// PATCH /api/notebooks/[id] - Update a notebook
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const body = await request.json()
const { name, icon, color, order } = body
// Verify ownership
const existing = await prisma.notebook.findUnique({
where: { id },
select: { userId: true }
})
if (!existing) {
return NextResponse.json(
{ success: false, error: 'Notebook not found' },
{ status: 404 }
)
}
if (existing.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Forbidden' },
{ status: 403 }
)
}
// Build update data
const updateData: any = {}
if (name !== undefined) updateData.name = name.trim()
if (icon !== undefined) updateData.icon = icon
if (color !== undefined) updateData.color = color
if (order !== undefined) updateData.order = order
// Update notebook
const notebook = await prisma.notebook.update({
where: { id },
data: updateData,
include: {
labels: true,
_count: {
select: { notes: true }
}
}
})
revalidatePath('/')
return NextResponse.json({
success: true,
...notebook,
notesCount: notebook._count.notes
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to update notebook' },
{ status: 500 }
)
}
}
// DELETE /api/notebooks/[id] - Delete a notebook
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
// Verify ownership and get notebook info
const notebook = await prisma.notebook.findUnique({
where: { id },
select: {
userId: true,
name: true,
_count: {
select: { notes: true, labels: true }
}
}
})
if (!notebook) {
return NextResponse.json(
{ success: false, error: 'Notebook not found' },
{ status: 404 }
)
}
if (notebook.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Forbidden' },
{ status: 403 }
)
}
// Delete notebook (cascade will handle labels and notes)
await prisma.notebook.delete({
where: { id }
})
revalidatePath('/')
return NextResponse.json({
success: true,
message: `Notebook "${notebook.name}" deleted`,
notesCount: notebook._count.notes,
labelsCount: notebook._count.labels
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to delete notebook' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
// POST /api/notebooks/reorder - Reorder notebooks
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { notebookIds } = body
if (!Array.isArray(notebookIds)) {
return NextResponse.json(
{ success: false, error: 'notebookIds must be an array' },
{ status: 400 }
)
}
// Verify all notebooks belong to the user
const notebooks = await prisma.notebook.findMany({
where: {
id: { in: notebookIds },
userId: session.user.id
},
select: { id: true }
})
if (notebooks.length !== notebookIds.length) {
return NextResponse.json(
{ success: false, error: 'One or more notebooks not found or unauthorized' },
{ status: 403 }
)
}
// Update order for each notebook
const updates = notebookIds.map((id, index) =>
prisma.notebook.update({
where: { id },
data: { order: index }
})
)
await prisma.$transaction(updates)
revalidatePath('/')
return NextResponse.json({
success: true,
message: 'Notebooks reordered successfully'
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to reorder notebooks' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
const DEFAULT_COLORS = ['#3B82F6', '#8B5CF6', '#EC4899', '#F59E0B', '#10B981', '#06B6D4']
const DEFAULT_ICONS = ['📁', '📚', '💼', '🎯', '📊', '🎨', '💡', '🔧']
// GET /api/notebooks - Get all notebooks for current user
export async function GET(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const notebooks = await prisma.notebook.findMany({
where: { userId: session.user.id },
include: {
labels: {
orderBy: { name: 'asc' }
},
_count: {
select: { notes: true }
}
},
orderBy: { order: 'asc' }
})
return NextResponse.json({
success: true,
notebooks: notebooks.map(nb => ({
...nb,
notesCount: nb._count.notes
}))
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to fetch notebooks' },
{ status: 500 }
)
}
}
// POST /api/notebooks - Create a new notebook
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const body = await request.json()
const { name, icon, color } = body
if (!name || typeof name !== 'string') {
return NextResponse.json(
{ success: false, error: 'Notebook name is required' },
{ status: 400 }
)
}
// Get the highest order value for this user
const highestOrder = await prisma.notebook.findFirst({
where: { userId: session.user.id },
orderBy: { order: 'desc' },
select: { order: true }
})
const nextOrder = (highestOrder?.order ?? -1) + 1
// Create notebook
const notebook = await prisma.notebook.create({
data: {
name: name.trim(),
icon: icon || DEFAULT_ICONS[Math.floor(Math.random() * DEFAULT_ICONS.length)],
color: color || DEFAULT_COLORS[Math.floor(Math.random() * DEFAULT_COLORS.length)],
order: nextOrder,
userId: session.user.id
},
include: {
labels: true,
_count: {
select: { notes: true }
}
}
})
revalidatePath('/')
return NextResponse.json({
success: true,
...notebook,
notesCount: notebook._count.notes
}, { status: 201 })
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to create notebook' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { auth } from '@/auth'
import { revalidatePath } from 'next/cache'
// POST /api/notes/[id]/move - Move a note to a notebook (or to Inbox)
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const { id } = await params
const body = await request.json()
const { notebookId } = body
// Get the note
const note = await prisma.note.findUnique({
where: { id },
select: {
id: true,
userId: true,
notebookId: true
}
})
if (!note) {
return NextResponse.json(
{ success: false, error: 'Note not found' },
{ status: 404 }
)
}
// Verify ownership
if (note.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Forbidden' },
{ status: 403 }
)
}
// If notebookId is provided, verify it exists and belongs to the user
if (notebookId !== null && notebookId !== '') {
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
select: { userId: true }
})
if (!notebook || notebook.userId !== session.user.id) {
return NextResponse.json(
{ success: false, error: 'Notebook not found or unauthorized' },
{ status: 403 }
)
}
}
// Update the note's notebook
// notebookId = null or "" means move to Inbox (Notes générales)
const updatedNote = await prisma.note.update({
where: { id },
data: {
notebookId: notebookId && notebookId !== '' ? notebookId : null
},
include: {
notebook: {
select: { id: true, name: true }
}
}
})
revalidatePath('/')
return NextResponse.json({
success: true,
data: updatedNote,
message: notebookId && notebookId !== ''
? `Note moved to "${updatedNote.notebook?.name || 'notebook'}"`
: 'Note moved to Inbox'
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to move note' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
// Helper to parse JSON fields
function parseNote(dbNote: any) {
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
}
}
// GET /api/notes/[id] - Get a single note
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const note = await prisma.note.findUnique({
where: { id }
})
if (!note) {
return NextResponse.json(
{ success: false, error: 'Note not found' },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
data: parseNote(note)
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to fetch note' },
{ status: 500 }
)
}
}
// PUT /api/notes/[id] - Update a note
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const updateData: any = { ...body }
// Stringify JSON fields if they exist
if ('checkItems' in body) {
updateData.checkItems = body.checkItems ? JSON.stringify(body.checkItems) : null
}
if ('labels' in body) {
updateData.labels = body.labels ? JSON.stringify(body.labels) : null
}
updateData.updatedAt = new Date()
const note = await prisma.note.update({
where: { id },
data: updateData
})
return NextResponse.json({
success: true,
data: parseNote(note)
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to update note' },
{ status: 500 }
)
}
}
// DELETE /api/notes/[id] - Delete a note
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await prisma.note.delete({
where: { id }
})
return NextResponse.json({
success: true,
message: 'Note deleted successfully'
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to delete note' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function POST(req: NextRequest) {
try {
// Check authentication
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
// Delete all notes for the user (cascade will handle labels-note relationships)
const result = await prisma.note.deleteMany({
where: {
userId: session.user.id
}
})
// Delete all labels for the user
await prisma.label.deleteMany({
where: {
userId: session.user.id
}
})
// Delete all notebooks for the user
await prisma.notebook.deleteMany({
where: {
userId: session.user.id
}
})
// Revalidate paths
revalidatePath('/')
revalidatePath('/settings/data')
return NextResponse.json({
success: true,
deletedNotes: result.count
})
} catch (error) {
console.error('Delete all error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to delete notes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
// Check authentication
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
// Fetch all notes with related data
const notes = await prisma.note.findMany({
where: {
userId: session.user.id
},
include: {
labels: {
select: {
id: true,
name: true
}
},
notebook: {
select: {
id: true,
name: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
// Fetch labels separately
const labels = await prisma.label.findMany({
where: {
userId: session.user.id
},
include: {
notes: {
select: {
id: true
}
}
}
})
// Fetch notebooks
const notebooks = await prisma.notebook.findMany({
where: {
userId: session.user.id
},
include: {
notes: {
select: {
id: true
}
}
}
})
// Create export object
const exportData = {
version: '1.0.0',
exportDate: new Date().toISOString(),
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name
},
data: {
labels: labels.map(label => ({
id: label.id,
name: label.name,
color: label.color,
noteCount: label.notes.length
})),
notebooks: notebooks.map(notebook => ({
id: notebook.id,
name: notebook.name,
description: notebook.description,
noteCount: notebook.notes.length
})),
notes: notes.map(note => ({
id: note.id,
title: note.title,
content: note.content,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
isPinned: note.isPinned,
notebookId: note.notebookId,
labels: note.labels.map(label => ({
id: label.id,
name: label.name
}))
}))
}
}
// Return as JSON file
const jsonString = JSON.stringify(exportData, null, 2)
return new NextResponse(jsonString, {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="memento-export-${new Date().toISOString().split('T')[0]}.json"`
}
})
} catch (error) {
console.error('Export error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to export notes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,158 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function POST(req: NextRequest) {
try {
// Check authentication
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json(
{ success: false, error: 'Unauthorized' },
{ status: 401 }
)
}
// Parse form data
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ success: false, error: 'No file provided' },
{ status: 400 }
)
}
// Parse JSON file
const text = await file.text()
let importData: any
try {
importData = JSON.parse(text)
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Invalid JSON file' },
{ status: 400 }
)
}
// Validate import data structure
if (!importData.data || !importData.data.notes) {
return NextResponse.json(
{ success: false, error: 'Invalid import format' },
{ status: 400 }
)
}
let importedNotes = 0
let importedLabels = 0
let importedNotebooks = 0
// Import labels first
if (importData.data.labels && Array.isArray(importData.data.labels)) {
for (const label of importData.data.labels) {
// Check if label already exists
const existing = await prisma.label.findFirst({
where: {
userId: session.user.id,
name: label.name
}
})
if (!existing) {
await prisma.label.create({
data: {
userId: session.user.id,
name: label.name,
color: label.color
}
})
importedLabels++
}
}
}
// Import notebooks
const notebookIdMap = new Map<string, string>()
if (importData.data.notebooks && Array.isArray(importData.data.notebooks)) {
for (const notebook of importData.data.notebooks) {
// Check if notebook already exists
const existing = await prisma.notebook.findFirst({
where: {
userId: session.user.id,
name: notebook.name
}
})
let newNotebookId
if (!existing) {
const created = await prisma.notebook.create({
data: {
userId: session.user.id,
name: notebook.name,
description: notebook.description || null,
position: 0
}
})
newNotebookId = created.id
notebookIdMap.set(notebook.id, newNotebookId)
importedNotebooks++
} else {
notebookIdMap.set(notebook.id, existing.id)
}
}
}
// Import notes
if (importData.data.notes && Array.isArray(importData.data.notes)) {
for (const note of importData.data.notes) {
// Map notebook ID
const mappedNotebookId = notebookIdMap.get(note.notebookId) || null
// Get label IDs
const labels = await prisma.label.findMany({
where: {
userId: session.user.id,
name: {
in: note.labels.map((l: any) => l.name)
}
}
})
// Create note
await prisma.note.create({
data: {
userId: session.user.id,
title: note.title || 'Untitled',
content: note.content,
isPinned: note.isPinned || false,
notebookId: mappedNotebookId,
labels: {
connect: labels.map(label => ({ id: label.id }))
}
}
})
importedNotes++
}
}
// Revalidate paths
revalidatePath('/')
revalidatePath('/settings/data')
return NextResponse.json({
success: true,
count: importedNotes,
labels: importedLabels,
notebooks: importedNotebooks
})
} catch (error) {
console.error('Import error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to import notes' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,162 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
import { CheckItem } from '@/lib/types'
// Helper to parse JSON fields
function parseNote(dbNote: any) {
return {
...dbNote,
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
images: dbNote.images ? JSON.parse(dbNote.images) : null,
}
}
// GET /api/notes - Get all notes
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const includeArchived = searchParams.get('archived') === 'true'
const search = searchParams.get('search')
let where: any = {}
if (!includeArchived) {
where.isArchived = false
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } }
]
}
const notes = await prisma.note.findMany({
where,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
return NextResponse.json({
success: true,
data: notes.map(parseNote)
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to fetch notes' },
{ status: 500 }
)
}
}
// POST /api/notes - Create a new note
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { title, content, color, type, checkItems, labels, images } = body
if (!content && type !== 'checklist') {
return NextResponse.json(
{ success: false, error: 'Content is required' },
{ status: 400 }
)
}
const note = await prisma.note.create({
data: {
title: title || null,
content: content || '',
color: color || 'default',
type: type || 'text',
checkItems: checkItems ? JSON.stringify(checkItems) : null,
labels: labels ? JSON.stringify(labels) : null,
images: images ? JSON.stringify(images) : null,
}
})
return NextResponse.json({
success: true,
data: parseNote(note)
}, { status: 201 })
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to create note' },
{ status: 500 }
)
}
}
// PUT /api/notes - Update an existing note
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
if (!id) {
return NextResponse.json(
{ success: false, error: 'Note ID is required' },
{ status: 400 }
)
}
const updateData: any = {}
if (title !== undefined) updateData.title = title
if (content !== undefined) updateData.content = content
if (color !== undefined) updateData.color = color
if (type !== undefined) updateData.type = type
if (checkItems !== undefined) updateData.checkItems = checkItems ? JSON.stringify(checkItems) : null
if (labels !== undefined) updateData.labels = labels ? JSON.stringify(labels) : null
if (isPinned !== undefined) updateData.isPinned = isPinned
if (isArchived !== undefined) updateData.isArchived = isArchived
if (images !== undefined) updateData.images = images ? JSON.stringify(images) : null
const note = await prisma.note.update({
where: { id },
data: updateData
})
return NextResponse.json({
success: true,
data: parseNote(note)
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to update note' },
{ status: 500 }
)
}
}
// DELETE /api/notes?id=xxx - Delete a note
export async function DELETE(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ success: false, error: 'Note ID is required' },
{ status: 400 }
)
}
await prisma.note.delete({
where: { id }
})
return NextResponse.json({
success: true,
message: 'Note deleted successfully'
})
} catch (error) {
return NextResponse.json(
{ success: false, error: 'Failed to delete note' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { randomUUID } from 'crypto'
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json(
{ error: 'No file uploaded' },
{ status: 400 }
)
}
const buffer = Buffer.from(await file.arrayBuffer())
const filename = `${randomUUID()}${path.extname(file.name)}`
// Ensure directory exists
const uploadDir = path.join(process.cwd(), 'public/uploads/notes')
await mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, filename)
await writeFile(filePath, buffer)
return NextResponse.json({
success: true,
url: `/uploads/notes/${filename}`
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to upload file' },
{ status: 500 }
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 B

View File

@@ -0,0 +1,492 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "tw-animate-css";
@import "vazirmatn/Vazirmatn-font-face.css";
@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: #64748b;
--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;
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.2 0.02 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
--primary-foreground: oklch(0.99 0 0); /* Blanc */
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
--secondary-foreground: oklch(0.2 0.02 230);
--muted: oklch(0.92 0.005 230);
--muted-foreground: oklch(0.6 0.01 230);
--accent: oklch(0.94 0.005 230);
--accent-foreground: oklch(0.2 0.02 230);
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
--input: oklch(0.98 0.003 230);
--ring: oklch(0.7 0.005 230);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.97 0.004 230);
--sidebar-foreground: oklch(0.2 0.02 230);
--sidebar-primary: oklch(0.45 0.08 230);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.005 230);
--sidebar-accent-foreground: oklch(0.2 0.02 230);
--sidebar-border: oklch(0.9 0.008 230);
--sidebar-ring: oklch(0.7 0.005 230);
}
[data-theme='light'] {
--background: oklch(0.985 0.003 230); /* Blanc grisâtre */
--foreground: oklch(0.2 0.02 230); /* Gris-bleu foncé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.2 0.02 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--primary: oklch(0.45 0.08 230); /* Gris-bleu doux */
--primary-foreground: oklch(0.99 0 0); /* Blanc */
--secondary: oklch(0.94 0.005 230); /* Gris-bleu très pâle */
--secondary-foreground: oklch(0.2 0.02 230);
--muted: oklch(0.92 0.005 230);
--muted-foreground: oklch(0.6 0.01 230);
--accent: oklch(0.94 0.005 230);
--accent-foreground: oklch(0.2 0.02 230);
--destructive: oklch(0.6 0.18 25); /* Rouge */
--border: oklch(0.9 0.008 230); /* Gris-bleu très clair */
--input: oklch(0.98 0.003 230);
--ring: oklch(0.7 0.005 230);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.02 230);
--sidebar: oklch(0.97 0.004 230);
--sidebar-foreground: oklch(0.2 0.02 230);
--sidebar-primary: oklch(0.45 0.08 230);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.94 0.005 230);
--sidebar-accent-foreground: oklch(0.2 0.02 230);
--sidebar-border: oklch(0.9 0.008 230);
--sidebar-ring: oklch(0.7 0.005 230);
}
[data-theme='light'].dark {
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
--card-foreground: oklch(0.97 0.003 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.24 0.006 230);
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.24 0.006 230);
--accent-foreground: oklch(0.97 0.003 230);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.28 0.01 230);
--input: oklch(0.2 0.006 230);
--ring: oklch(0.6 0.01 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--sidebar: oklch(0.12 0.005 230);
--sidebar-foreground: oklch(0.97 0.003 230);
--sidebar-primary: oklch(0.55 0.08 230);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.24 0.006 230);
--sidebar-accent-foreground: oklch(0.97 0.003 230);
--sidebar-border: oklch(0.28 0.01 230);
--sidebar-ring: oklch(0.6 0.01 230);
}
.dark {
--background: oklch(0.14 0.005 230); /* Noir grisâtre */
--foreground: oklch(0.97 0.003 230); /* Blanc grisâtre */
--card: oklch(0.18 0.006 230); /* Gris-bleu foncé */
--card-foreground: oklch(0.97 0.003 230);
--popover: oklch(0.18 0.006 230);
--popover-foreground: oklch(0.97 0.003 230);
--primary: oklch(0.55 0.08 230); /* Gris-bleu plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.24 0.006 230);
--secondary-foreground: oklch(0.97 0.003 230);
--muted: oklch(0.22 0.006 230);
--muted-foreground: oklch(0.55 0.01 230);
--accent: oklch(0.24 0.006 230);
--accent-foreground: oklch(0.97 0.003 230);
--destructive: oklch(0.65 0.18 25);
--border: oklch(0.28 0.01 230);
--input: oklch(0.2 0.006 230);
--ring: oklch(0.6 0.01 230);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.12 0.005 230);
--sidebar-foreground: oklch(0.97 0.003 230);
--sidebar-primary: oklch(0.55 0.08 230);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.24 0.006 230);
--sidebar-accent-foreground: oklch(0.97 0.003 230);
--sidebar-border: oklch(0.28 0.01 230);
--sidebar-ring: oklch(0.6 0.01 230);
}
[data-theme='midnight'] {
--background: oklch(0.94 0.005 250); /* Gris-bleu très pâle */
--foreground: oklch(0.18 0.03 250); /* Gris-bleu très foncé */
--card: oklch(0.97 0.006 250); /* Gris-bleu pâle */
--card-foreground: oklch(0.18 0.03 250);
--primary: oklch(0.5 0.12 250); /* Gris-bleu saturé */
--primary-foreground: oklch(0.99 0 0); /* Blanc */
--secondary: oklch(0.2 0.01 250);
--secondary-foreground: oklch(0.18 0.03 250);
--muted: oklch(0.22 0.01 250);
--muted-foreground: oklch(0.55 0.02 250);
--accent: oklch(0.25 0.015 250);
--accent-foreground: oklch(0.18 0.03 250);
--destructive: oklch(0.6 0.22 25);
--border: oklch(0.85 0.015 250);
--input: oklch(0.25 0.01 250);
--ring: oklch(0.65 0.015 250);
--popover: oklch(0.97 0.006 250);
--popover-foreground: oklch(0.18 0.03 250);
--sidebar: oklch(0.9 0.01 250);
--sidebar-foreground: oklch(0.18 0.03 250);
--sidebar-primary: oklch(0.5 0.12 250);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.25 0.015 250);
--sidebar-accent-foreground: oklch(0.18 0.03 250);
--sidebar-border: oklch(0.85 0.015 250);
--sidebar-ring: oklch(0.65 0.015 250);
}
[data-theme='midnight'].dark {
--background: oklch(0.1 0.01 250); /* Noir profond */
--foreground: oklch(0.96 0.005 250); /* Blanc grisâtre */
--card: oklch(0.15 0.015 250); /* Gris-bleu très foncé */
--card-foreground: oklch(0.96 0.005 250);
--primary: oklch(0.6 0.12 250); /* Gris-bleu vibrant */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.18 0.015 250);
--secondary-foreground: oklch(0.96 0.005 250);
--muted: oklch(0.2 0.015 250);
--muted-foreground: oklch(0.5 0.02 250);
--accent: oklch(0.22 0.02 250);
--accent-foreground: oklch(0.96 0.005 250);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.3 0.02 250);
--input: oklch(0.22 0.02 250);
--ring: oklch(0.55 0.02 250);
--popover: oklch(0.15 0.015 250);
--popover-foreground: oklch(0.96 0.005 250);
--sidebar: oklch(0.08 0.01 250);
--sidebar-foreground: oklch(0.96 0.005 250);
--sidebar-primary: oklch(0.6 0.12 250);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.22 0.02 250);
--sidebar-accent-foreground: oklch(0.96 0.005 250);
--sidebar-border: oklch(0.3 0.02 250);
--sidebar-ring: oklch(0.55 0.02 250);
}
[data-theme='blue'] {
--background: oklch(0.985 0.005 225); /* Blanc légèrement bleuté */
--foreground: oklch(0.18 0.035 225); /* Gris-bleu foncé saturé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.18 0.035 225);
--primary: oklch(0.5 0.15 225); /* Bleu vibrant */
--primary-foreground: oklch(0.99 0 0); /* Blanc */
--secondary: oklch(0.93 0.008 225);
--secondary-foreground: oklch(0.18 0.035 225);
--muted: oklch(0.9 0.01 225);
--muted-foreground: oklch(0.58 0.015 225);
--accent: oklch(0.93 0.01 225);
--accent-foreground: oklch(0.18 0.035 225);
--destructive: oklch(0.6 0.2 25);
--border: oklch(0.87 0.012 225);
--input: oklch(0.95 0.01 225);
--ring: oklch(0.65 0.015 225);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.035 225);
--sidebar: oklch(0.965 0.008 225);
--sidebar-foreground: oklch(0.18 0.035 225);
--sidebar-primary: oklch(0.5 0.15 225);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.93 0.01 225);
--sidebar-accent-foreground: oklch(0.18 0.035 225);
--sidebar-border: oklch(0.87 0.012 225);
--sidebar-ring: oklch(0.65 0.015 225);
}
[data-theme='blue'].dark {
--background: oklch(0.13 0.008 225); /* Noir légèrement bleuté */
--foreground: oklch(0.97 0.006 225); /* Blanc légèrement bleuté */
--card: oklch(0.17 0.01 225); /* Gris-bleu foncé */
--card-foreground: oklch(0.97 0.006 225);
--primary: oklch(0.6 0.15 225); /* Bleu vibrant plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.22 0.015 225);
--secondary-foreground: oklch(0.97 0.006 225);
--muted: oklch(0.25 0.02 225);
--muted-foreground: oklch(0.52 0.018 225);
--accent: oklch(0.25 0.025 225);
--accent-foreground: oklch(0.97 0.006 225);
--destructive: oklch(0.65 0.22 25);
--border: oklch(0.32 0.018 225);
--input: oklch(0.25 0.02 225);
--ring: oklch(0.55 0.02 225);
--popover: oklch(0.17 0.01 225);
--popover-foreground: oklch(0.97 0.006 225);
--sidebar: oklch(0.1 0.01 225);
--sidebar-foreground: oklch(0.97 0.006 225);
--sidebar-primary: oklch(0.6 0.15 225);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.25 0.025 225);
--sidebar-accent-foreground: oklch(0.97 0.006 225);
--sidebar-border: oklch(0.32 0.018 225);
--sidebar-ring: oklch(0.55 0.02 225);
}
[data-theme='sepia'] {
--background: oklch(0.985 0.004 45); /* Blanc légèrement doré */
--foreground: oklch(0.2 0.015 45); /* Gris-brun foncé */
--card: oklch(1 0 0); /* Blanc pur */
--card-foreground: oklch(0.2 0.015 45);
--primary: oklch(0.45 0.08 45); /* Gris-brun chaud */
--primary-foreground: oklch(0.99 0 0); /* Blanc */
--secondary: oklch(0.94 0.008 45);
--secondary-foreground: oklch(0.2 0.015 45);
--muted: oklch(0.91 0.01 45);
--muted-foreground: oklch(0.6 0.012 45);
--accent: oklch(0.93 0.01 45);
--accent-foreground: oklch(0.2 0.015 45);
--destructive: oklch(0.6 0.2 25);
--border: oklch(0.88 0.012 45);
--input: oklch(0.97 0.008 45);
--ring: oklch(0.68 0.01 45);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2 0.015 45);
--sidebar: oklch(0.96 0.01 45);
--sidebar-foreground: oklch(0.2 0.015 45);
--sidebar-primary: oklch(0.45 0.08 45);
--sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.93 0.01 45);
--sidebar-accent-foreground: oklch(0.2 0.015 45);
--sidebar-border: oklch(0.88 0.012 45);
--sidebar-ring: oklch(0.68 0.01 45);
}
[data-theme='sepia'].dark {
--background: oklch(0.15 0.008 45); /* Noir légèrement bruni */
--foreground: oklch(0.97 0.005 45); /* Blanc légèrement bruni */
--card: oklch(0.19 0.01 45); /* Gris-brun foncé */
--card-foreground: oklch(0.97 0.005 45);
--primary: oklch(0.55 0.08 45); /* Gris-brun plus clair */
--primary-foreground: oklch(0.1 0 0); /* Noir */
--secondary: oklch(0.25 0.015 45);
--secondary-foreground: oklch(0.97 0.005 45);
--muted: oklch(0.23 0.02 45);
--muted-foreground: oklch(0.55 0.012 45);
--accent: oklch(0.27 0.018 45);
--accent-foreground: oklch(0.97 0.005 45);
--destructive: oklch(0.65 0.2 25);
--border: oklch(0.3 0.018 45);
--input: oklch(0.27 0.02 45);
--ring: oklch(0.58 0.02 45);
--popover: oklch(0.19 0.01 45);
--popover-foreground: oklch(0.97 0.005 45);
--sidebar: oklch(0.12 0.01 45);
--sidebar-foreground: oklch(0.97 0.005 45);
--sidebar-primary: oklch(0.55 0.08 45);
--sidebar-primary-foreground: oklch(0.1 0 0);
--sidebar-accent: oklch(0.27 0.018 45);
--sidebar-accent-foreground: oklch(0.97 0.005 45);
--sidebar-border: oklch(0.3 0.018 45);
--sidebar-ring: oklch(0.58 0.02 45);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
/* Set base font size on html element - this affects all rem units */
html {
font-size: var(--user-font-size, 16px);
}
body {
@apply bg-background text-foreground;
/* Body inherits from html, can be adjusted per language */
}
/* Latin languages use default (inherits from html) */
[lang='en'] body,
[lang='fr'] body {
font-size: 1rem;
/* Uses html font size */
}
/* Persian/Farsi font with larger size for better readability */
[lang='fa'] body {
font-family: 'Vazirmatn', var(--font-sans), sans-serif !important;
/* Base 110% for Persian = 1.1rem */
font-size: calc(1.1rem * var(--user-font-size-factor, 1));
}
/* Ensure Persian text uses Vazirmatn even in nested elements */
[lang='fa'] * {
font-family: 'Vazirmatn', sans-serif !important;
}
}
/* ============================================
Toast Notifications - Fix UI Blocking Issue
============================================ */
/* Ensure the toaster container doesn't block clicks on the page underneath */
[data-sonner-toaster] {
pointer-events: none !important;
}
/* Ensure all children of toaster don't block except the toast itself */
[data-sonner-toaster]>* {
pointer-events: none !important;
}
/* But allow interaction with the toast itself (buttons, close, etc) */
[data-sonner-toast] {
pointer-events: auto !important;
}
/* Also ensure any overlay or background doesn't block */
[data-sonner-toaster]::before,
[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

@@ -0,0 +1,83 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toast";
import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
const inter = Inter({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Memento - Your Digital Notepad",
description: "A beautiful note-taking app inspired by Google Keep, built with Next.js 16",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ url: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
],
apple: "/apple-icon.png",
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Memento",
},
};
export const viewport: Viewport = {
themeColor: "#f59e0b",
};
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;
}>) {
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)
])
return (
<html suppressHydrationWarning>
<body className={inter.className}>
<script
dangerouslySetInnerHTML={{
__html: getThemeScript(userSettings.theme),
}}
/>
<SessionProviderWrapper>
<ThemeInitializer theme={userSettings.theme} fontSize={aiSettings.fontSize} />
{children}
<Toaster />
</SessionProviderWrapper>
</body>
</html>
);
}

View File

@@ -0,0 +1,61 @@
'use client'
import { useState } from 'react'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
export default function TestTitleSuggestionsPage() {
const [content, setContent] = useState('')
const { suggestions, isAnalyzing, error } = useTitleSuggestions({
content,
enabled: true // Always enabled for testing
})
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h1>Test Title Suggestions</h1>
<div style={{ marginBottom: '20px' }}>
<label>Content (need 50+ words):</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ width: '100%', height: '200px', marginTop: '10px' }}
placeholder="Type at least 50 words here..."
/>
</div>
<div style={{ marginBottom: '20px', padding: '10px', background: '#f0f0f0' }}>
<p><strong>Word count:</strong> {wordCount} / 50</p>
<p><strong>Status:</strong> {isAnalyzing ? 'Analyzing...' : 'Idle'}</p>
{error && <p style={{ color: 'red' }}><strong>Error:</strong> {error}</p>}
</div>
<div>
<h2>Suggestions ({suggestions.length}):</h2>
{suggestions.length > 0 ? (
<ul>
{suggestions.map((s, i) => (
<li key={i}>
<strong>{s.title}</strong> (confidence: {s.confidence}%)
{s.reasoning && <p> {s.reasoning}</p>}
</li>
))}
</ul>
) : (
<p style={{ color: '#666' }}>No suggestions yet. Type 50+ words and wait 2 seconds.</p>
)}
</div>
<div style={{ marginTop: '20px' }}>
<button onClick={() => {
setContent('word '.repeat(50))
}}>
Fill with 50 words (test)
</button>
</div>
</div>
)
}