chore: clean up repo for public release
- Remove BMAD framework, IDE configs, dev screenshots, test files, internal docs, and backup files - Rename keep-notes/ to memento-note/ - Update all references from keep-notes to memento-note - Add Apache 2.0 license with Commons Clause (non-commercial restriction) - Add clean .gitignore and .env.docker.example
This commit is contained in:
259
memento-note/app/(main)/admin/ai-test/ai-tester.tsx
Normal file
259
memento-note/app/(main)/admin/ai-test/ai-tester.tsx
Normal 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(
|
||||
`✅ ${t('admin.aiTest.testSuccessToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
||||
{
|
||||
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.error(
|
||||
`❌ ${t('admin.aiTest.testFailedToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
||||
{
|
||||
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: t('admin.aiTest.testing'), model: t('admin.aiTest.testing') }
|
||||
|
||||
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">
|
||||
{t('admin.aiTest.technicalDetails')}
|
||||
</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.testingType', { type: type === 'tags' ? 'tags generation' : 'embeddings' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
memento-note/app/(main)/admin/ai-test/page.tsx
Normal file
102
memento-note/app/(main)/admin/ai-test/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, TestTube } from 'lucide-react'
|
||||
import { AI_TESTER } from './ai-tester'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function AITestPage() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
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" />
|
||||
{t('admin.aiTest.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t('admin.aiTest.description')}
|
||||
</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>
|
||||
{t('admin.aiTest.tagsTestTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('admin.aiTest.tagsTestDescription')}
|
||||
</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>
|
||||
{t('admin.aiTest.embeddingsTestTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('admin.aiTest.embeddingsTestDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<AI_TESTER type="embeddings" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Section */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>ℹ️ {t('admin.aiTest.howItWorksTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.tagsGenerationTest')}</h4>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>{t('admin.aiTest.tagsStep1')}</li>
|
||||
<li>{t('admin.aiTest.tagsStep2')}</li>
|
||||
<li>{t('admin.aiTest.tagsStep3')}</li>
|
||||
<li>{t('admin.aiTest.tagsStep4')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.embeddingsTestLabel')}</h4>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>{t('admin.aiTest.embeddingsStep1')}</li>
|
||||
<li>{t('admin.aiTest.embeddingsStep2')}</li>
|
||||
<li>{t('admin.aiTest.embeddingsStep3')}</li>
|
||||
<li>{t('admin.aiTest.embeddingsStep4')}</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">💡 {t('admin.aiTest.tipTitle')}</p>
|
||||
<p className="text-amber-800 dark:text-amber-200 mt-1">
|
||||
{t('admin.aiTest.tipContent')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
memento-note/app/(main)/admin/ai/page.tsx
Normal file
147
memento-note/app/(main)/admin/ai/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
memento-note/app/(main)/admin/create-user-dialog.tsx
Normal file
80
memento-note/app/(main)/admin/create-user-dialog.tsx
Normal 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">{t('admin.users.roles.user')}</option>
|
||||
<option value="ADMIN">{t('admin.users.roles.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{t('admin.users.createUser')}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
23
memento-note/app/(main)/admin/layout.tsx
Normal file
23
memento-note/app/(main)/admin/layout.tsx
Normal 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 h-full bg-gray-50 dark:bg-zinc-950">
|
||||
<AdminSidebar />
|
||||
<AdminContentArea>{children}</AdminContentArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
memento-note/app/(main)/admin/loading.tsx
Normal file
20
memento-note/app/(main)/admin/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export default function AdminLoading() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-9 w-48 bg-muted rounded-md mb-2" />
|
||||
<div className="h-4 w-72 bg-muted rounded-md" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border bg-white dark:bg-zinc-900 p-6 space-y-4">
|
||||
<div className="h-5 w-40 bg-muted rounded" />
|
||||
<div className="h-px bg-border" />
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 w-full bg-muted rounded" />
|
||||
<div className="h-4 w-3/4 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
memento-note/app/(main)/admin/page.tsx
Normal file
59
memento-note/app/(main)/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1083
memento-note/app/(main)/admin/settings/admin-settings-form.tsx
Normal file
1083
memento-note/app/(main)/admin/settings/admin-settings-form.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
memento-note/app/(main)/admin/settings/page.tsx
Normal file
23
memento-note/app/(main)/admin/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
memento-note/app/(main)/admin/user-list.tsx
Normal file
83
memento-note/app/(main)/admin/user-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
memento-note/app/(main)/admin/users/page.tsx
Normal file
29
memento-note/app/(main)/admin/users/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
memento-note/app/(main)/agents/agents-page-client.tsx
Normal file
281
memento-note/app/(main)/agents/agents-page-client.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Agents Page Client
|
||||
* Main client component for the agents page.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { Plus, Bot, LifeBuoy, Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
import { AgentCard } from '@/components/agents/agent-card'
|
||||
import { AgentForm } from '@/components/agents/agent-form'
|
||||
import { AgentTemplates } from '@/components/agents/agent-templates'
|
||||
import { AgentRunLog } from '@/components/agents/agent-run-log'
|
||||
import { AgentHelp } from '@/components/agents/agent-help'
|
||||
import {
|
||||
createAgent,
|
||||
updateAgent,
|
||||
getAgents,
|
||||
} from '@/app/actions/agent-actions'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface Notebook {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string | null
|
||||
}
|
||||
|
||||
interface AgentItem {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
type?: string | null
|
||||
role: string
|
||||
sourceUrls?: string | null
|
||||
sourceNotebookId?: string | null
|
||||
targetNotebookId?: string | null
|
||||
frequency: string
|
||||
isEnabled: boolean
|
||||
lastRun: string | Date | null
|
||||
createdAt: string | Date
|
||||
updatedAt: string | Date
|
||||
tools?: string | null
|
||||
maxSteps?: number
|
||||
notifyEmail?: boolean
|
||||
includeImages?: boolean
|
||||
_count: { actions: number }
|
||||
actions: { id: string; status: string; createdAt: string | Date }[]
|
||||
notebook?: { id: string; name: string; icon?: string | null } | null
|
||||
}
|
||||
|
||||
interface AgentsPageClientProps {
|
||||
agents: AgentItem[]
|
||||
notebooks: Notebook[]
|
||||
}
|
||||
|
||||
const typeFilterOptions = [
|
||||
{ value: '', labelKey: 'agents.filterAll' },
|
||||
{ value: 'scraper', labelKey: 'agents.types.scraper' },
|
||||
{ value: 'researcher', labelKey: 'agents.types.researcher' },
|
||||
{ value: 'monitor', labelKey: 'agents.types.monitor' },
|
||||
{ value: 'custom', labelKey: 'agents.types.custom' },
|
||||
] as const
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export function AgentsPageClient({
|
||||
agents: initialAgents,
|
||||
notebooks,
|
||||
}: AgentsPageClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [agents, setAgents] = useState(initialAgents)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingAgent, setEditingAgent] = useState<AgentItem | null>(null)
|
||||
const [logAgent, setLogAgent] = useState<{ id: string; name: string } | null>(null)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
|
||||
const refreshAgents = useCallback(async () => {
|
||||
try {
|
||||
const updated = await getAgents()
|
||||
setAgents(updated)
|
||||
} catch {
|
||||
// Silent
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleToggle = useCallback((id: string, isEnabled: boolean) => {
|
||||
setAgents(prev => prev.map(a => a.id === id ? { ...a, isEnabled } : a))
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
setEditingAgent(null)
|
||||
setShowForm(true)
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback((id: string) => {
|
||||
const agent = agents.find(a => a.id === id)
|
||||
if (agent) {
|
||||
setEditingAgent(agent)
|
||||
setShowForm(true)
|
||||
}
|
||||
}, [agents])
|
||||
|
||||
const handleSave = useCallback(async (formData: FormData) => {
|
||||
const data = {
|
||||
name: formData.get('name') as string,
|
||||
description: (formData.get('description') as string) || undefined,
|
||||
type: formData.get('type') as string,
|
||||
role: formData.get('role') as string,
|
||||
sourceUrls: formData.get('sourceUrls') ? JSON.parse(formData.get('sourceUrls') as string) : undefined,
|
||||
sourceNotebookId: (formData.get('sourceNotebookId') as string) || undefined,
|
||||
targetNotebookId: (formData.get('targetNotebookId') as string) || undefined,
|
||||
frequency: formData.get('frequency') as string,
|
||||
tools: formData.get('tools') ? JSON.parse(formData.get('tools') as string) : undefined,
|
||||
maxSteps: formData.get('maxSteps') ? Number(formData.get('maxSteps')) : undefined,
|
||||
notifyEmail: formData.get('notifyEmail') === 'true',
|
||||
includeImages: formData.get('includeImages') === 'true',
|
||||
}
|
||||
|
||||
if (editingAgent) {
|
||||
await updateAgent(editingAgent.id, data)
|
||||
toast.success(t('agents.toasts.updated'))
|
||||
} else {
|
||||
await createAgent(data)
|
||||
toast.success(t('agents.toasts.created'))
|
||||
}
|
||||
|
||||
setShowForm(false)
|
||||
setEditingAgent(null)
|
||||
await refreshAgents()
|
||||
}, [editingAgent, refreshAgents, t])
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
return agents.filter(agent => {
|
||||
const matchesType = !typeFilter || (agent.type || 'scraper') === typeFilter
|
||||
if (!searchQuery.trim()) return matchesType
|
||||
const q = searchQuery.toLowerCase()
|
||||
const matchesSearch =
|
||||
(agent.name || '').toLowerCase().includes(q) ||
|
||||
(agent.description && agent.description.toLowerCase().includes(q))
|
||||
return matchesType && matchesSearch
|
||||
})
|
||||
}, [agents, searchQuery, typeFilter])
|
||||
|
||||
const existingAgentNames = useMemo(() => agents.map(a => a.name), [agents])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4 mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-2xl shadow-sm border border-primary/20">
|
||||
<Bot className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('agents.title')}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t('agents.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => setShowHelp(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-primary bg-primary/10 border border-primary/20 rounded-lg hover:bg-primary/15 transition-colors"
|
||||
>
|
||||
<LifeBuoy className="w-4 h-4" />
|
||||
{t('agents.help.btnLabel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('agents.newAgent')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Agents grid */}
|
||||
{agents.length > 0 && (
|
||||
<div className="mb-10">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
||||
{t('agents.myAgents')}
|
||||
</h3>
|
||||
|
||||
{/* Search and filter */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 w-full sm:max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('agents.searchPlaceholder')}
|
||||
className="w-full pl-9 pr-3 py-2 text-sm bg-card border border-border rounded-lg outline-none focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{typeFilterOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTypeFilter(opt.value)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
|
||||
typeFilter === opt.value
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-muted text-muted-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{t(opt.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredAgents.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredAgents.map(agent => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onEdit={handleEdit}
|
||||
onRefresh={refreshAgents}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="w-10 h-10 text-muted-foreground/30 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">{t('agents.noResults')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{agents.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center mb-10">
|
||||
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-medium text-muted-foreground mb-2">{t('agents.noAgents')}</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
{t('agents.noAgentsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Templates */}
|
||||
<AgentTemplates onInstalled={refreshAgents} existingAgentNames={existingAgentNames} />
|
||||
|
||||
{/* Form modal */}
|
||||
{showForm && (
|
||||
<AgentForm
|
||||
agent={editingAgent}
|
||||
notebooks={notebooks}
|
||||
onSave={handleSave}
|
||||
onCancel={() => { setShowForm(false); setEditingAgent(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Run log modal */}
|
||||
{logAgent && (
|
||||
<AgentRunLog
|
||||
agentId={logAgent.id}
|
||||
agentName={logAgent.name}
|
||||
onClose={() => setLogAgent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Help modal */}
|
||||
{showHelp && (
|
||||
<AgentHelp onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
memento-note/app/(main)/agents/page.tsx
Normal file
33
memento-note/app/(main)/agents/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAgents } from '@/app/actions/agent-actions'
|
||||
import { AgentsPageClient } from './agents-page-client'
|
||||
|
||||
export default async function AgentsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const [agents, notebooks] = await Promise.all([
|
||||
getAgents(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
})
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-background">
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AgentsPageClient
|
||||
agents={agents}
|
||||
notebooks={notebooks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
memento-note/app/(main)/archive/page.tsx
Normal file
16
memento-note/app/(main)/archive/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
memento-note/app/(main)/chat/page.tsx
Normal file
44
memento-note/app/(main)/chat/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { ChatContainer } from '@/components/chat/chat-container'
|
||||
import { getConversations } from '@/app/actions/chat-actions'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Chat IA | Memento',
|
||||
description: 'Discutez avec vos notes et vos agents IA',
|
||||
}
|
||||
|
||||
export default async function ChatPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Fetch initial data
|
||||
const [conversations, notebooks, config] = await Promise.all([
|
||||
getConversations(),
|
||||
prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
orderBy: { order: 'asc' }
|
||||
}),
|
||||
getSystemConfig(),
|
||||
])
|
||||
|
||||
// Check if web search tools are configured
|
||||
const webSearchAvailable = !!(
|
||||
config.WEB_SEARCH_PROVIDER || config.BRAVE_SEARCH_API_KEY || config.SEARXNG_URL || config.JINA_API_KEY
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-white dark:bg-[#1a1c22]">
|
||||
<ChatContainer
|
||||
initialConversations={conversations}
|
||||
notebooks={notebooks}
|
||||
webSearchAvailable={webSearchAvailable}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
memento-note/app/(main)/lab/page.tsx
Normal file
56
memento-note/app/(main)/lab/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Metadata } from 'next'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getCanvases, createCanvas } from '@/app/actions/canvas-actions'
|
||||
import { LabHeader } from '@/components/lab/lab-header'
|
||||
import { CanvasWrapper } from '@/components/lab/canvas-wrapper'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Le Lab | Memento',
|
||||
description: 'Visualisez et connectez vos idées sur un canvas interactif',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
export default async function LabPage(props: {
|
||||
searchParams: Promise<{ id?: string }>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const id = searchParams.id
|
||||
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/login')
|
||||
|
||||
const canvases = await getCanvases()
|
||||
|
||||
// Resolve current canvas correctly
|
||||
const currentCanvasId = searchParams.id || (canvases.length > 0 ? canvases[0].id : undefined)
|
||||
const currentCanvas = currentCanvasId ? canvases.find(c => c.id === currentCanvasId) : undefined
|
||||
|
||||
// Wrapper for server action creation
|
||||
async function handleCreate() {
|
||||
'use server'
|
||||
const newCanvas = await createCanvas()
|
||||
redirect(`/lab?id=${newCanvas.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full bg-slate-50 dark:bg-[#1a1c22] overflow-hidden">
|
||||
<LabHeader
|
||||
canvases={canvases}
|
||||
currentCanvasId={currentCanvasId ?? null}
|
||||
onCreateCanvas={handleCreate}
|
||||
/>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<CanvasWrapper
|
||||
key={currentCanvasId || 'new'}
|
||||
canvasId={currentCanvas?.id}
|
||||
name={currentCanvas?.name || "Nouvel Espace de Pensée"}
|
||||
initialData={currentCanvas?.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
memento-note/app/(main)/layout.tsx
Normal file
41
memento-note/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
import { loadTranslations } from "@/lib/i18n/load-translations";
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Run auth + language detection + translation loading in parallel
|
||||
const [session, initialLanguage] = await Promise.all([
|
||||
auth(),
|
||||
detectUserLanguage(),
|
||||
]);
|
||||
|
||||
// Load initial translations server-side to prevent hydration mismatch
|
||||
const initialTranslations = await loadTranslations(initialLanguage);
|
||||
|
||||
return (
|
||||
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
<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-e border-slate-200 dark:border-slate-800 overflow-y-auto hidden md:flex" user={session?.user} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background-light dark:bg-background-dark p-4 scroll-smooth">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ProvidersWrapper>
|
||||
);
|
||||
}
|
||||
27
memento-note/app/(main)/page.tsx
Normal file
27
memento-note/app/(main)/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getAllNotes } from '@/app/actions/notes'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { HomeClient } from '@/components/home-client'
|
||||
|
||||
export default async function HomePage() {
|
||||
const [allNotes, settings] = await Promise.all([
|
||||
getAllNotes(),
|
||||
getAISettings(),
|
||||
])
|
||||
|
||||
const notesViewMode =
|
||||
settings?.notesViewMode === 'masonry'
|
||||
? 'masonry' as const
|
||||
: settings?.notesViewMode === 'tabs' || settings?.notesViewMode === 'list'
|
||||
? 'tabs' as const
|
||||
: 'masonry' as const
|
||||
|
||||
return (
|
||||
<HomeClient
|
||||
initialNotes={allNotes}
|
||||
initialSettings={{
|
||||
showRecentNotes: settings?.showRecentNotes !== false,
|
||||
notesViewMode,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
9
memento-note/app/(main)/reminders/page.tsx
Normal file
9
memento-note/app/(main)/reminders/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getNotesWithReminders } from '@/app/actions/notes'
|
||||
import { RemindersPage } from '@/components/reminders-page'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function RemindersRoute() {
|
||||
const notes = await getNotesWithReminders()
|
||||
return <RemindersPage notes={notes} />
|
||||
}
|
||||
136
memento-note/app/(main)/settings/about/page.tsx
Normal file
136
memento-note/app/(main)/settings/about/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
memento-note/app/(main)/settings/ai/ai-settings-header.tsx
Normal file
16
memento-note/app/(main)/settings/ai/ai-settings-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
memento-note/app/(main)/settings/ai/page-new.tsx
Normal file
184
memento-note/app/(main)/settings/ai/page-new.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
memento-note/app/(main)/settings/ai/page.tsx
Normal file
22
memento-note/app/(main)/settings/ai/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
memento-note/app/(main)/settings/appearance/appearance-form.tsx
Normal file
132
memento-note/app/(main)/settings/appearance/appearance-form.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'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
|
||||
initialCardSizeMode?: string
|
||||
}
|
||||
|
||||
export function AppearanceSettingsForm({ initialTheme, initialFontSize, initialCardSizeMode = 'variable' }: AppearanceSettingsFormProps) {
|
||||
const router = useRouter()
|
||||
const [theme, setTheme] = useState(initialTheme)
|
||||
const [fontSize, setFontSize] = useState(initialFontSize)
|
||||
const [cardSizeMode, setCardSizeMode] = useState(initialCardSizeMode)
|
||||
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 })
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
setCardSizeMode(value)
|
||||
localStorage.setItem('card-size-mode', value)
|
||||
await updateUser({ cardSizeMode: value as 'variable' | 'uniform' })
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SettingsSection, SettingSelect } from '@/components/settings'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { updateUserSettings } from '@/app/actions/user-settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface AppearanceSettingsClientProps {
|
||||
initialFontSize: string
|
||||
initialTheme: string
|
||||
initialNotesViewMode: 'masonry' | 'tabs'
|
||||
initialCardSizeMode?: 'variable' | 'uniform'
|
||||
}
|
||||
|
||||
export function AppearanceSettingsClient({ initialFontSize, initialTheme, initialNotesViewMode, initialCardSizeMode = 'variable' }: AppearanceSettingsClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [theme, setTheme] = useState(initialTheme || 'light')
|
||||
const [fontSize, setFontSize] = useState(initialFontSize || 'medium')
|
||||
const [notesViewMode, setNotesViewMode] = useState<'masonry' | 'tabs'>(initialNotesViewMode)
|
||||
const [cardSizeMode, setCardSizeMode] = useState<'variable' | 'uniform'>(initialCardSizeMode)
|
||||
|
||||
const handleThemeChange = async (value: string) => {
|
||||
setTheme(value)
|
||||
localStorage.setItem('theme-preference', value)
|
||||
|
||||
const root = document.documentElement
|
||||
root.removeAttribute('data-theme')
|
||||
root.classList.remove('dark')
|
||||
|
||||
if (value === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
||||
} else if (value === 'dark') {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.setAttribute('data-theme', value)
|
||||
if (['midnight'].includes(value)) root.classList.add('dark')
|
||||
}
|
||||
|
||||
await updateUserSettings({ theme: value as 'light' | 'dark' | 'auto' })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleFontSizeChange = async (value: string) => {
|
||||
setFontSize(value)
|
||||
|
||||
const fontSizeMap: Record<string, string> = {
|
||||
'small': '14px', 'medium': '16px', 'large': '18px', 'extra-large': '20px'
|
||||
}
|
||||
document.documentElement.style.setProperty('--user-font-size', fontSizeMap[value] || '16px')
|
||||
|
||||
await updateAISettings({ fontSize: value as any })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleNotesViewChange = async (value: string) => {
|
||||
const mode = value === 'tabs' ? 'tabs' : 'masonry'
|
||||
setNotesViewMode(mode)
|
||||
await updateAISettings({ notesViewMode: mode })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleCardSizeModeChange = async (value: string) => {
|
||||
const mode = value === 'uniform' ? 'uniform' : 'variable'
|
||||
setCardSizeMode(mode)
|
||||
localStorage.setItem('card-size-mode', mode)
|
||||
await updateUserSettings({ cardSizeMode: mode })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('appearance.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('appearance.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.theme')}
|
||||
icon={<span className="text-2xl">🎨</span>}
|
||||
description={t('settings.themeLight') + ' / ' + t('settings.themeDark')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.theme')}
|
||||
description={t('appearance.selectTheme')}
|
||||
value={theme}
|
||||
options={[
|
||||
{ value: 'light', label: t('settings.themeLight') },
|
||||
{ value: 'dark', label: t('settings.themeDark') },
|
||||
{ value: 'sepia', label: 'Sepia' },
|
||||
{ value: 'midnight', label: 'Midnight' },
|
||||
{ value: 'auto', label: t('settings.themeSystem') },
|
||||
]}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('profile.fontSize')}
|
||||
icon={<span className="text-2xl">📝</span>}
|
||||
description={t('profile.fontSizeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('profile.fontSize')}
|
||||
description={t('profile.selectFontSize')}
|
||||
value={fontSize}
|
||||
options={[
|
||||
{ value: 'small', label: t('profile.fontSizeSmall') },
|
||||
{ value: 'medium', label: t('profile.fontSizeMedium') },
|
||||
{ value: 'large', label: t('profile.fontSizeLarge') },
|
||||
]}
|
||||
onChange={handleFontSizeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('appearance.notesViewLabel')}
|
||||
icon={<span className="text-2xl">📋</span>}
|
||||
description={t('appearance.notesViewDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('appearance.notesViewLabel')}
|
||||
description={t('appearance.notesViewDescription')}
|
||||
value={notesViewMode}
|
||||
options={[
|
||||
{ value: 'masonry', label: t('appearance.notesViewMasonry') },
|
||||
{ value: 'tabs', label: t('appearance.notesViewTabs') },
|
||||
]}
|
||||
onChange={handleNotesViewChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.cardSizeMode')}
|
||||
icon={<span className="text-2xl">📐</span>}
|
||||
description={t('settings.cardSizeModeDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.cardSizeMode')}
|
||||
description={t('settings.selectCardSizeMode')}
|
||||
value={cardSizeMode}
|
||||
options={[
|
||||
{ value: 'variable', label: t('settings.cardSizeVariable') },
|
||||
{ value: 'uniform', label: t('settings.cardSizeUniform') },
|
||||
]}
|
||||
onChange={handleCardSizeModeChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
memento-note/app/(main)/settings/appearance/page.tsx
Normal file
26
memento-note/app/(main)/settings/appearance/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { getUserSettings } from '@/app/actions/user-settings'
|
||||
import { AppearanceSettingsClient } from './appearance-settings-client'
|
||||
|
||||
export default async function AppearanceSettingsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
const [aiSettings, userSettings] = await Promise.all([
|
||||
getAISettings(),
|
||||
getUserSettings()
|
||||
])
|
||||
|
||||
return (
|
||||
<AppearanceSettingsClient
|
||||
initialFontSize={aiSettings.fontSize}
|
||||
initialTheme={userSettings.theme}
|
||||
initialNotesViewMode={aiSettings.notesViewMode === 'masonry' ? 'masonry' : 'tabs'}
|
||||
initialCardSizeMode={userSettings.cardSizeMode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
191
memento-note/app/(main)/settings/data/page.tsx
Normal file
191
memento-note/app/(main)/settings/data/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'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'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function DataSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
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-note-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 }))
|
||||
router.refresh()
|
||||
} 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'))
|
||||
router.refresh()
|
||||
}
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SettingsSection, SettingToggle, SettingSelect } from '@/components/settings'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface GeneralSettingsClientProps {
|
||||
initialSettings: {
|
||||
preferredLanguage: string
|
||||
emailNotifications: boolean
|
||||
desktopNotifications: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) {
|
||||
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [language, setLanguage] = useState(initialSettings.preferredLanguage || 'auto')
|
||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
await updateAISettings({ preferredLanguage: value as any })
|
||||
|
||||
if (value === 'auto') {
|
||||
localStorage.removeItem('user-language')
|
||||
toast.success(t('settings.languageAuto') || 'Language set to Auto')
|
||||
} else {
|
||||
localStorage.setItem('user-language', value)
|
||||
setContextLanguage(value as any)
|
||||
toast.success(t('profile.languageUpdateSuccess') || 'Language updated')
|
||||
}
|
||||
|
||||
setTimeout(() => router.refresh(), 300)
|
||||
}
|
||||
|
||||
const handleEmailNotificationsChange = async (enabled: boolean) => {
|
||||
setEmailNotifications(enabled)
|
||||
await updateAISettings({ emailNotifications: enabled })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
const handleDesktopNotificationsChange = async (enabled: boolean) => {
|
||||
setDesktopNotifications(enabled)
|
||||
await updateAISettings({ desktopNotifications: enabled })
|
||||
toast.success(t('settings.settingsSaved') || 'Saved')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{t('generalSettings.title')}</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{t('generalSettings.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.language')}
|
||||
icon={<span className="text-2xl">🌍</span>}
|
||||
description={t('profile.languagePreferencesDescription')}
|
||||
>
|
||||
<SettingSelect
|
||||
label={t('settings.language')}
|
||||
description={t('settings.selectLanguage')}
|
||||
value={language}
|
||||
options={[
|
||||
{ value: 'auto', label: t('profile.autoDetect') },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'fa', label: 'فارسی' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'hi', label: 'हिन्दी' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'pl', label: 'Polski' },
|
||||
]}
|
||||
onChange={handleLanguageChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.notifications')}
|
||||
icon={<span className="text-2xl">🔔</span>}
|
||||
description={t('settings.notificationsDesc')}
|
||||
>
|
||||
<SettingToggle
|
||||
label={t('settings.emailNotifications')}
|
||||
description={t('settings.emailNotificationsDesc')}
|
||||
checked={emailNotifications}
|
||||
onChange={handleEmailNotificationsChange}
|
||||
/>
|
||||
<SettingToggle
|
||||
label={t('settings.desktopNotifications')}
|
||||
description={t('settings.desktopNotificationsDesc')}
|
||||
checked={desktopNotifications}
|
||||
onChange={handleDesktopNotificationsChange}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
memento-note/app/(main)/settings/general/page.tsx
Normal file
15
memento-note/app/(main)/settings/general/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { GeneralSettingsClient } from './general-settings-client'
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
const settings = await getAISettings()
|
||||
|
||||
return <GeneralSettingsClient initialSettings={settings} />
|
||||
}
|
||||
25
memento-note/app/(main)/settings/layout.tsx
Normal file
25
memento-note/app/(main)/settings/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
memento-note/app/(main)/settings/loading.tsx
Normal file
26
memento-note/app/(main)/settings/loading.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-9 w-64 bg-muted rounded-md mb-2" />
|
||||
<div className="h-4 w-96 bg-muted rounded-md" />
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 bg-muted rounded-full" />
|
||||
<div className="h-5 w-40 bg-muted rounded-md" />
|
||||
</div>
|
||||
<div className="h-px bg-border" />
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/30">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-32 bg-muted rounded" />
|
||||
<div className="h-3 w-56 bg-muted rounded" />
|
||||
</div>
|
||||
<div className="h-6 w-11 bg-muted rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
memento-note/app/(main)/settings/mcp/page.tsx
Normal file
23
memento-note/app/(main)/settings/mcp/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { McpSettingsPanel } from '@/components/mcp/mcp-settings-panel'
|
||||
import { listMcpKeys, getMcpServerStatus } from '@/app/actions/mcp-keys'
|
||||
|
||||
export default async function McpSettingsPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
const [keys, serverStatus] = await Promise.all([
|
||||
listMcpKeys(),
|
||||
getMcpServerStatus(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<McpSettingsPanel initialKeys={keys} serverStatus={serverStatus} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
memento-note/app/(main)/settings/page.tsx
Normal file
7
memento-note/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
// Immediate redirect to the first settings sub-page
|
||||
// This avoids loading the heavy settings/page.tsx client component on first visit
|
||||
export default function SettingsIndexPage() {
|
||||
redirect('/settings/general')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
155
memento-note/app/(main)/settings/profile/page-new.tsx
Normal file
155
memento-note/app/(main)/settings/profile/page-new.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
memento-note/app/(main)/settings/profile/page.tsx
Normal file
44
memento-note/app/(main)/settings/profile/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ProfileForm } from './profile-form'
|
||||
import prisma from '@/lib/prisma'
|
||||
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')
|
||||
}
|
||||
|
||||
// Parallel queries
|
||||
const [user, aiSettings] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { name: true, email: true, role: true }
|
||||
}),
|
||||
prisma.userAISettings.findUnique({
|
||||
where: { userId: session.user.id }
|
||||
})
|
||||
])
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const userAISettings = {
|
||||
preferredLanguage: aiSettings?.preferredLanguage || 'auto',
|
||||
showRecentNotes: aiSettings?.showRecentNotes ?? false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<ProfilePageHeader />
|
||||
<ProfileForm user={user} userAISettings={userAISettings} />
|
||||
|
||||
{/* AI Settings Link */}
|
||||
<AISettingsLinkCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
memento-note/app/(main)/settings/profile/profile-form.tsx
Normal file
179
memento-note/app/(main)/settings/profile/profile-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
156
memento-note/app/(main)/support/page.tsx
Normal file
156
memento-note/app/(main)/support/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
memento-note/app/(main)/trash/page.tsx
Normal file
21
memento-note/app/(main)/trash/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getTrashedNotes } from '@/app/actions/notes'
|
||||
import { MasonryGrid } from '@/components/masonry-grid'
|
||||
import { TrashHeader } from '@/components/trash-header'
|
||||
import { TrashEmptyState } from './trash-empty-state'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function TrashPage() {
|
||||
const notes = await getTrashedNotes()
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<TrashHeader noteCount={notes.length} />
|
||||
{notes.length > 0 ? (
|
||||
<MasonryGrid notes={notes} isTrashView />
|
||||
) : (
|
||||
<TrashEmptyState />
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
20
memento-note/app/(main)/trash/trash-empty-state.tsx
Normal file
20
memento-note/app/(main)/trash/trash-empty-state.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function TrashEmptyState() {
|
||||
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.emptyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user