feat: add reminders page, BMad skills upgrade, MCP server refactor
- Add reminders page with navigation support - Upgrade BMad builder module to skills-based architecture - Refactor MCP server: extract tools and auth into separate modules - Add connections cache, custom AI provider support - Update prisma schema and generated client - Various UI/UX improvements and i18n updates - Add service worker for PWA support Made-with: Cursor
This commit is contained in:
@@ -64,14 +64,14 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
`✅ ${type === 'tags' ? 'Tags' : 'Embeddings'} Test Successful!`,
|
||||
`✅ ${t('admin.aiTest.testSuccessToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
||||
{
|
||||
description: `Provider: ${data.provider} | Time: ${endTime - startTime}ms`
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.error(
|
||||
`❌ ${type === 'tags' ? 'Tags' : 'Embeddings'} Test Failed`,
|
||||
`❌ ${t('admin.aiTest.testFailedToast', { type: type === 'tags' ? 'Tags' : 'Embeddings' })}`,
|
||||
{
|
||||
description: data.error || 'Unknown error'
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
}
|
||||
|
||||
const getProviderInfo = () => {
|
||||
if (!config) return { provider: 'Loading...', model: 'Loading...' }
|
||||
if (!config) return { provider: t('admin.aiTest.testing'), model: t('admin.aiTest.testing') }
|
||||
|
||||
if (type === 'tags') {
|
||||
return {
|
||||
@@ -232,7 +232,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
{result.details && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs cursor-pointer text-red-600 dark:text-red-400">
|
||||
Technical details
|
||||
{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)}
|
||||
@@ -250,7 +250,7 @@ export function AI_TESTER({ type }: { type: 'tags' | 'embeddings' }) {
|
||||
<div className="text-center py-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t('admin.aiTest.testing')} {type === 'tags' ? 'tags generation' : 'embeddings'}...
|
||||
{t('admin.aiTest.testingType', { type: type === 'tags' ? 'tags generation' : 'embeddings' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, TestTube } from 'lucide-react'
|
||||
import { AI_TESTER } from './ai-tester'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default async function AITestPage() {
|
||||
const session = await auth()
|
||||
|
||||
if ((session?.user as any)?.role !== 'ADMIN') {
|
||||
redirect('/')
|
||||
}
|
||||
export default function AITestPage() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10 px-4 max-w-6xl">
|
||||
@@ -25,10 +22,10 @@ export default async function AITestPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
||||
<TestTube className="h-8 w-8" />
|
||||
AI Provider Testing
|
||||
{t('admin.aiTest.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Test your AI providers for tag generation and semantic search embeddings
|
||||
{t('admin.aiTest.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,10 +37,10 @@ export default async function AITestPage() {
|
||||
<CardHeader className="bg-primary/5 dark:bg-primary/10">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🏷️</span>
|
||||
Tags Generation Test
|
||||
{t('admin.aiTest.tagsTestTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Test the AI provider responsible for automatic tag suggestions
|
||||
{t('admin.aiTest.tagsTestDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
@@ -56,10 +53,10 @@ export default async function AITestPage() {
|
||||
<CardHeader className="bg-green-50/50 dark:bg-green-950/20">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl">🔍</span>
|
||||
Embeddings Test
|
||||
{t('admin.aiTest.embeddingsTestTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Test the AI provider responsible for semantic search embeddings
|
||||
{t('admin.aiTest.embeddingsTestDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
@@ -71,32 +68,31 @@ export default async function AITestPage() {
|
||||
{/* Info Section */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>ℹ️ How Testing Works</CardTitle>
|
||||
<CardTitle>ℹ️ {t('admin.aiTest.howItWorksTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2">🏷️ Tags Generation Test:</h4>
|
||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.tagsGenerationTest')}</h4>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Sends a sample note to the AI provider</li>
|
||||
<li>Requests 3-5 relevant tags based on the content</li>
|
||||
<li>Displays the generated tags with confidence scores</li>
|
||||
<li>Measures response time</li>
|
||||
<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">🔍 Embeddings Test:</h4>
|
||||
<h4 className="font-semibold mb-2">{t('admin.aiTest.embeddingsTestLabel')}</h4>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>Sends a sample text to the embedding provider</li>
|
||||
<li>Generates a vector representation (list of numbers)</li>
|
||||
<li>Displays embedding dimensions and sample values</li>
|
||||
<li>Verifies the vector is valid and properly formatted</li>
|
||||
<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">💡 Tip:</p>
|
||||
<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">
|
||||
You can use different providers for tags and embeddings! For example, use Ollama (free) for tags
|
||||
and OpenAI (best quality) for embeddings to optimize costs and performance.
|
||||
{t('admin.aiTest.tipContent')}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -66,8 +66,8 @@ export function CreateUserDialog() {
|
||||
name="role"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="USER">User</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="USER">{t('admin.users.roles.user')}</option>
|
||||
<option value="ADMIN">{t('admin.users.roles.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { updateSystemConfig, testSMTP } from '@/app/actions/admin-settings'
|
||||
import { getOllamaModels } from '@/app/actions/ollama'
|
||||
import { getCustomModels, getCustomEmbeddingModels } from '@/app/actions/custom-provider'
|
||||
import { toast } from 'sonner'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
@@ -21,15 +22,10 @@ interface AvailableModels {
|
||||
}
|
||||
|
||||
const MODELS_2026 = {
|
||||
// Removed hardcoded Ollama models in favor of dynamic fetching
|
||||
openai: {
|
||||
tags: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-4', 'gpt-3.5-turbo'],
|
||||
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
|
||||
},
|
||||
custom: {
|
||||
tags: ['gpt-4o-mini', 'gpt-4o', 'claude-3-haiku', 'claude-3-sonnet', 'llama-3.1-8b'],
|
||||
embeddings: ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002']
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||
@@ -57,6 +53,14 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const [isLoadingTagsModels, setIsLoadingTagsModels] = useState(false)
|
||||
const [isLoadingEmbeddingsModels, setIsLoadingEmbeddingsModels] = useState(false)
|
||||
|
||||
// Custom provider dynamic models
|
||||
const [customTagsModels, setCustomTagsModels] = useState<string[]>([])
|
||||
const [customEmbeddingsModels, setCustomEmbeddingsModels] = useState<string[]>([])
|
||||
const [isLoadingCustomTagsModels, setIsLoadingCustomTagsModels] = useState(false)
|
||||
const [isLoadingCustomEmbeddingsModels, setIsLoadingCustomEmbeddingsModels] = useState(false)
|
||||
const [customTagsSearch, setCustomTagsSearch] = useState('')
|
||||
const [customEmbeddingsSearch, setCustomEmbeddingsSearch] = useState('')
|
||||
|
||||
|
||||
// Fetch Ollama models
|
||||
const fetchOllamaModels = useCallback(async (type: 'tags' | 'embeddings', url: string) => {
|
||||
@@ -83,6 +87,33 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch Custom provider models — tags use /v1/models, embeddings use /v1/embeddings/models
|
||||
const fetchCustomModels = useCallback(async (type: 'tags' | 'embeddings', url: string, apiKey?: string) => {
|
||||
if (!url) return
|
||||
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(true)
|
||||
else setIsLoadingCustomEmbeddingsModels(true)
|
||||
|
||||
try {
|
||||
const result = type === 'embeddings'
|
||||
? await getCustomEmbeddingModels(url, apiKey)
|
||||
: await getCustomModels(url, apiKey)
|
||||
|
||||
if (result.success && result.models.length > 0) {
|
||||
if (type === 'tags') setCustomTagsModels(result.models)
|
||||
else setCustomEmbeddingsModels(result.models)
|
||||
} else {
|
||||
toast.error(`Impossible de récupérer les modèles : ${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error('Erreur lors de la récupération des modèles')
|
||||
} finally {
|
||||
if (type === 'tags') setIsLoadingCustomTagsModels(false)
|
||||
else setIsLoadingCustomEmbeddingsModels(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial fetch for Ollama models if provider is selected
|
||||
useEffect(() => {
|
||||
if (tagsProvider === 'ollama') {
|
||||
@@ -96,10 +127,23 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
if (embeddingsProvider === 'ollama') {
|
||||
const url = config.OLLAMA_BASE_URL_EMBEDDING || config.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
fetchOllamaModels('embeddings', url)
|
||||
} else if (embeddingsProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('embeddings', url, key)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [embeddingsProvider])
|
||||
|
||||
useEffect(() => {
|
||||
if (tagsProvider === 'custom') {
|
||||
const url = config.CUSTOM_OPENAI_BASE_URL || ''
|
||||
const key = config.CUSTOM_OPENAI_API_KEY || ''
|
||||
if (url) fetchCustomModels('tags', url, key)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tagsProvider])
|
||||
|
||||
const handleSaveSecurity = async (formData: FormData) => {
|
||||
setIsSaving(true)
|
||||
const data = {
|
||||
@@ -270,7 +314,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const defaultModels: Record<string, string> = {
|
||||
ollama: '',
|
||||
openai: MODELS_2026.openai.tags[0],
|
||||
custom: MODELS_2026.custom.tags[0],
|
||||
custom: '',
|
||||
}
|
||||
setSelectedTagsModel(defaultModels[newProvider] || '')
|
||||
}}
|
||||
@@ -361,7 +405,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_TAGS">{t('admin.ai.baseUrl')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_BASE_URL_TAGS" name="CUSTOM_OPENAI_BASE_URL_TAGS" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="CUSTOM_OPENAI_BASE_URL_TAGS"
|
||||
name="CUSTOM_OPENAI_BASE_URL_TAGS"
|
||||
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_TAGS') as HTMLInputElement
|
||||
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_TAGS') as HTMLInputElement
|
||||
fetchCustomModels('tags', urlInput.value, keyInput.value)
|
||||
}}
|
||||
disabled={isLoadingCustomTagsModels}
|
||||
title="Récupérer les modèles"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingCustomTagsModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_TAGS">{t('admin.ai.apiKey')}</Label>
|
||||
@@ -369,18 +434,41 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_TAGS_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
{customTagsModels.length > 0 && (
|
||||
<Input
|
||||
placeholder="Rechercher un modèle..."
|
||||
value={customTagsSearch}
|
||||
onChange={(e) => setCustomTagsSearch(e.target.value)}
|
||||
className="mb-1"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
id="AI_MODEL_TAGS_CUSTOM"
|
||||
name="AI_MODEL_TAGS_CUSTOM"
|
||||
value={selectedTagsModel}
|
||||
onChange={(e) => setSelectedTagsModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
className={`flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${customTagsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
|
||||
size={customTagsModels.length > 0 ? Math.min(8, customTagsModels.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase())).length) : 1}
|
||||
>
|
||||
{MODELS_2026.custom.tags.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
{customTagsModels.length > 0 ? (
|
||||
customTagsModels
|
||||
.filter(m => m.toLowerCase().includes(customTagsSearch.toLowerCase()))
|
||||
.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
selectedTagsModel
|
||||
? <option value={selectedTagsModel}>{selectedTagsModel}</option>
|
||||
: <option value="" disabled>Cliquez sur ↺ pour récupérer les modèles</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.commonModelsDescription')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomTagsModels
|
||||
? 'Récupération des modèles...'
|
||||
: customTagsModels.length > 0
|
||||
? `${customTagsModels.length} modèle(s) disponible(s)`
|
||||
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -409,7 +497,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const defaultModels: Record<string, string> = {
|
||||
ollama: '',
|
||||
openai: MODELS_2026.openai.embeddings[0],
|
||||
custom: MODELS_2026.custom.embeddings[0],
|
||||
custom: '',
|
||||
}
|
||||
setSelectedEmbeddingModel(defaultModels[newProvider] || '')
|
||||
}}
|
||||
@@ -503,7 +591,28 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_BASE_URL_EMBEDDING">{t('admin.ai.baseUrl')}</Label>
|
||||
<Input id="CUSTOM_OPENAI_BASE_URL_EMBEDDING" name="CUSTOM_OPENAI_BASE_URL_EMBEDDING" defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''} placeholder="https://api.example.com/v1" />
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="CUSTOM_OPENAI_BASE_URL_EMBEDDING"
|
||||
name="CUSTOM_OPENAI_BASE_URL_EMBEDDING"
|
||||
defaultValue={config.CUSTOM_OPENAI_BASE_URL || ''}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const urlInput = document.getElementById('CUSTOM_OPENAI_BASE_URL_EMBEDDING') as HTMLInputElement
|
||||
const keyInput = document.getElementById('CUSTOM_OPENAI_API_KEY_EMBEDDING') as HTMLInputElement
|
||||
fetchCustomModels('embeddings', urlInput.value, keyInput.value)
|
||||
}}
|
||||
disabled={isLoadingCustomEmbeddingsModels}
|
||||
title="Récupérer les modèles"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingCustomEmbeddingsModels ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="CUSTOM_OPENAI_API_KEY_EMBEDDING">{t('admin.ai.apiKey')}</Label>
|
||||
@@ -511,18 +620,41 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_MODEL_EMBEDDING_CUSTOM">{t('admin.ai.model')}</Label>
|
||||
{customEmbeddingsModels.length > 0 && (
|
||||
<Input
|
||||
placeholder="Rechercher un modèle..."
|
||||
value={customEmbeddingsSearch}
|
||||
onChange={(e) => setCustomEmbeddingsSearch(e.target.value)}
|
||||
className="mb-1"
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
id="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
name="AI_MODEL_EMBEDDING_CUSTOM"
|
||||
value={selectedEmbeddingModel}
|
||||
onChange={(e) => setSelectedEmbeddingModel(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
className={`flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${customEmbeddingsModels.length > 0 ? 'h-auto min-h-[180px]' : 'h-10'}`}
|
||||
size={customEmbeddingsModels.length > 0 ? Math.min(8, customEmbeddingsModels.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase())).length) : 1}
|
||||
>
|
||||
{MODELS_2026.custom.embeddings.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))}
|
||||
{customEmbeddingsModels.length > 0 ? (
|
||||
customEmbeddingsModels
|
||||
.filter(m => m.toLowerCase().includes(customEmbeddingsSearch.toLowerCase()))
|
||||
.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
) : (
|
||||
selectedEmbeddingModel
|
||||
? <option value={selectedEmbeddingModel}>{selectedEmbeddingModel}</option>
|
||||
: <option value="" disabled>Cliquez sur ↺ pour récupérer les modèles</option>
|
||||
)}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.ai.commonEmbeddingModels')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isLoadingCustomEmbeddingsModels
|
||||
? 'Récupération des modèles...'
|
||||
: customEmbeddingsModels.length > 0
|
||||
? `${customEmbeddingsModels.length} modèle(s) disponible(s)`
|
||||
: 'Renseignez l\'URL et cliquez sur ↺ pour charger les modèles'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -602,7 +734,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<CardFooter className="flex justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{t('admin.smtp.saveSettings')}</Button>
|
||||
<Button type="button" variant="secondary" onClick={handleTestEmail} disabled={isTesting}>
|
||||
{isTesting ? t('admin.smtp.sending') : t('admin.smtp.testEmail')}
|
||||
|
||||
9
keep-notes/app/(main)/reminders/page.tsx
Normal file
9
keep-notes/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} />
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserAISettingsData = {
|
||||
titleSuggestions?: boolean
|
||||
@@ -48,7 +48,7 @@ export async function updateAISettings(settings: UserAISettingsData) {
|
||||
|
||||
revalidatePath('/settings/ai', 'page')
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('ai-settings')
|
||||
updateTag('ai-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
114
keep-notes/app/actions/custom-provider.ts
Normal file
114
keep-notes/app/actions/custom-provider.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
'use server'
|
||||
|
||||
interface OpenAIModel {
|
||||
id: string
|
||||
object: string
|
||||
created?: number
|
||||
owned_by?: string
|
||||
}
|
||||
|
||||
interface OpenAIModelsResponse {
|
||||
object: string
|
||||
data: OpenAIModel[]
|
||||
}
|
||||
|
||||
async function fetchModelsFromEndpoint(
|
||||
endpoint: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenAIModelsResponse
|
||||
|
||||
const modelIds = (data.data || [])
|
||||
.map((m) => m.id)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
|
||||
return { success: true, models: modelIds }
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch provider models:', error)
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
error: error.message || 'Failed to connect to provider',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all models from a custom OpenAI-compatible provider.
|
||||
* Uses GET /v1/models (standard endpoint).
|
||||
*/
|
||||
export async function getCustomModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
return fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch embedding-specific models from a custom provider.
|
||||
* Tries GET /v1/embeddings/models first (OpenRouter-specific endpoint that returns
|
||||
* only embedding models). Falls back to GET /v1/models filtered by common
|
||||
* embedding model name patterns if the dedicated endpoint is unavailable.
|
||||
*/
|
||||
export async function getCustomEmbeddingModels(
|
||||
baseUrl: string,
|
||||
apiKey?: string
|
||||
): Promise<{ success: boolean; models: string[]; error?: string }> {
|
||||
if (!baseUrl) {
|
||||
return { success: false, models: [], error: 'Base URL is required' }
|
||||
}
|
||||
|
||||
const cleanUrl = baseUrl.replace(/\/$/, '').replace(/\/v1$/, '')
|
||||
|
||||
// Try the OpenRouter-specific embeddings models endpoint first
|
||||
const embeddingsEndpoint = await fetchModelsFromEndpoint(
|
||||
`${cleanUrl}/v1/embeddings/models`,
|
||||
apiKey
|
||||
)
|
||||
|
||||
if (embeddingsEndpoint.success && embeddingsEndpoint.models.length > 0) {
|
||||
return embeddingsEndpoint
|
||||
}
|
||||
|
||||
// Fallback: fetch all models and filter by common embedding name patterns
|
||||
const allModels = await fetchModelsFromEndpoint(`${cleanUrl}/v1/models`, apiKey)
|
||||
|
||||
if (!allModels.success) {
|
||||
return allModels
|
||||
}
|
||||
|
||||
const embeddingKeywords = ['embed', 'embedding', 'ada', 'e5', 'bge', 'gte', 'minilm']
|
||||
const filtered = allModels.models.filter((id) =>
|
||||
embeddingKeywords.some((kw) => id.toLowerCase().includes(kw))
|
||||
)
|
||||
|
||||
// If the filter finds nothing, return all models so the user can still pick
|
||||
return {
|
||||
success: true,
|
||||
models: filtered.length > 0 ? filtered : allModels.models,
|
||||
}
|
||||
}
|
||||
@@ -5,43 +5,26 @@ import prisma from '@/lib/prisma'
|
||||
import { Note, CheckItem } from '@/lib/types'
|
||||
import { auth } from '@/auth'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { parseNote as parseNoteUtil, cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||||
|
||||
// Helper function to parse JSON strings from database
|
||||
// Wrapper for parseNote that validates embeddings
|
||||
function parseNote(dbNote: any): Note {
|
||||
// Parse embedding
|
||||
const embedding = dbNote.embedding ? JSON.parse(dbNote.embedding) : null
|
||||
const note = parseNoteUtil(dbNote)
|
||||
|
||||
// Validate embedding if present
|
||||
if (embedding && Array.isArray(embedding)) {
|
||||
const validation = validateEmbedding(embedding)
|
||||
if (note.embedding && Array.isArray(note.embedding)) {
|
||||
const validation = validateEmbedding(note.embedding)
|
||||
if (!validation.valid) {
|
||||
// Don't include invalid embedding in the returned note
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
embedding: null, // Exclude invalid embedding
|
||||
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
||||
size: dbNote.size || 'small',
|
||||
...note,
|
||||
embedding: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
embedding,
|
||||
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
||||
size: dbNote.size || 'small',
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
// Helper to get hash color for labels (copied from utils)
|
||||
@@ -170,6 +153,46 @@ export async function getNotes(includeArchived = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get notes with reminders (upcoming, overdue, done)
|
||||
export async function getNotesWithReminders() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return [];
|
||||
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
isArchived: false,
|
||||
reminder: { not: null }
|
||||
},
|
||||
orderBy: { reminder: 'asc' }
|
||||
})
|
||||
|
||||
return notes.map(parseNote)
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes with reminders:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Mark a reminder as done / undone
|
||||
export async function toggleReminderDone(noteId: string, done: boolean) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId, userId: session.user.id },
|
||||
data: { isReminderDone: done }
|
||||
})
|
||||
revalidatePath('/reminders')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error toggling reminder done:', error)
|
||||
return { error: 'Failed to update reminder' }
|
||||
}
|
||||
}
|
||||
|
||||
// Get archived notes only
|
||||
export async function getArchivedNotes() {
|
||||
const session = await auth();
|
||||
@@ -329,50 +352,7 @@ export async function createNote(data: {
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
let embeddingString: string | null = null;
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(data.content);
|
||||
if (embedding) embeddingString = JSON.stringify(embedding);
|
||||
} catch (e) {
|
||||
console.error('Embedding generation failed:', e);
|
||||
}
|
||||
|
||||
// AUTO-LABELING: If no labels provided and auto-labeling is enabled, suggest labels
|
||||
let labelsToUse = data.labels || null;
|
||||
if ((!labelsToUse || labelsToUse.length === 0) && data.notebookId) {
|
||||
try {
|
||||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true);
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70);
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
data.content,
|
||||
data.notebookId,
|
||||
session.user.id
|
||||
);
|
||||
|
||||
// Apply suggestions with confidence >= threshold
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
.map(s => s.label);
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
labelsToUse = appliedLabels;
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AUTO-LABELING] Failed to suggest labels:', error);
|
||||
// Continue without auto-labeling on error
|
||||
}
|
||||
}
|
||||
|
||||
// Save note to DB immediately (fast!) — AI operations run in background after
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
@@ -381,27 +361,83 @@ export async function createNote(data: {
|
||||
color: data.color || 'default',
|
||||
type: data.type || 'text',
|
||||
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
||||
labels: labelsToUse ? JSON.stringify(labelsToUse) : null,
|
||||
labels: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : null,
|
||||
images: data.images ? JSON.stringify(data.images) : null,
|
||||
links: data.links ? JSON.stringify(data.links) : null,
|
||||
isArchived: data.isArchived || false,
|
||||
reminder: data.reminder || null,
|
||||
isMarkdown: data.isMarkdown || false,
|
||||
size: data.size || 'small',
|
||||
embedding: embeddingString,
|
||||
embedding: null, // Generated in background
|
||||
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
|
||||
autoGenerated: data.autoGenerated || null,
|
||||
notebookId: data.notebookId || null, // Assign note to notebook if provided
|
||||
notebookId: data.notebookId || null,
|
||||
}
|
||||
})
|
||||
|
||||
// Sync labels to ensure Label records exist
|
||||
if (labelsToUse && labelsToUse.length > 0) {
|
||||
await syncLabels(session.user.id, labelsToUse)
|
||||
// Sync user-provided labels immediately
|
||||
if (data.labels && data.labels.length > 0) {
|
||||
await syncLabels(session.user.id, data.labels)
|
||||
}
|
||||
|
||||
// Revalidate main page (handles both inbox and notebook views via query params)
|
||||
revalidatePath('/')
|
||||
|
||||
// Fire-and-forget: run AI operations in background without blocking the response
|
||||
const userId = session.user.id
|
||||
const noteId = note.id
|
||||
const content = data.content
|
||||
const notebookId = data.notebookId
|
||||
const hasUserLabels = data.labels && data.labels.length > 0
|
||||
|
||||
// Use setImmediate-like pattern to not block the response
|
||||
;(async () => {
|
||||
try {
|
||||
// Background task 1: Generate embedding
|
||||
const provider = getAIProvider(await getSystemConfig())
|
||||
const embedding = await provider.getEmbeddings(content)
|
||||
if (embedding) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { embedding: JSON.stringify(embedding) }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding generation failed:', e)
|
||||
}
|
||||
|
||||
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
||||
if (!hasUserLabels && notebookId) {
|
||||
try {
|
||||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true)
|
||||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||||
|
||||
if (autoLabelingEnabled) {
|
||||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||||
content,
|
||||
notebookId,
|
||||
userId
|
||||
)
|
||||
|
||||
const appliedLabels = suggestions
|
||||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||||
.map(s => s.label)
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { labels: JSON.stringify(appliedLabels) }
|
||||
})
|
||||
await syncLabels(userId, appliedLabels)
|
||||
revalidatePath('/')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BG] Auto-labeling failed:', error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return parseNote(note)
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error)
|
||||
@@ -433,23 +469,42 @@ export async function updateNote(id: string, data: {
|
||||
try {
|
||||
const oldNote = await prisma.note.findUnique({
|
||||
where: { id, userId: session.user.id },
|
||||
select: { labels: true, notebookId: true }
|
||||
select: { labels: true, notebookId: true, reminder: true }
|
||||
})
|
||||
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
||||
const oldNotebookId = oldNote?.notebookId
|
||||
|
||||
const updateData: any = { ...data }
|
||||
|
||||
if (data.content !== undefined) {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(data.content);
|
||||
updateData.embedding = embedding ? JSON.stringify(embedding) : null;
|
||||
} catch (e) {
|
||||
console.error('Embedding regeneration failed:', e);
|
||||
// Reset isReminderDone only when reminder date actually changes (not on every save)
|
||||
if ('reminder' in data && data.reminder !== null) {
|
||||
const newTime = new Date(data.reminder as Date).getTime()
|
||||
const oldTime = oldNote?.reminder ? new Date(oldNote.reminder).getTime() : null
|
||||
if (newTime !== oldTime) {
|
||||
updateData.isReminderDone = false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embedding in background — don't block the update
|
||||
if (data.content !== undefined) {
|
||||
const noteId = id
|
||||
const content = data.content
|
||||
;(async () => {
|
||||
try {
|
||||
const provider = getAIProvider(await getSystemConfig());
|
||||
const embedding = await provider.getEmbeddings(content);
|
||||
if (embedding) {
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: { embedding: JSON.stringify(embedding) }
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BG] Embedding regeneration failed:', e);
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
||||
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
||||
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
||||
@@ -766,13 +821,25 @@ export async function getAllNotes(includeArchived = false) {
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out archived shared notes if needed
|
||||
const sharedNotes = acceptedShares
|
||||
.map(share => share.note)
|
||||
.filter(note => includeArchived || !note.isArchived)
|
||||
|
||||
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||||
|
||||
// Derive pinned and recent notes
|
||||
const pinned = allNotes.filter((note: Note) => note.isPinned)
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
sevenDaysAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
const recent = allNotes
|
||||
.filter((note: Note) => {
|
||||
return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo
|
||||
})
|
||||
.sort((a, b) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 3)
|
||||
|
||||
return allNotes
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error)
|
||||
@@ -808,6 +875,7 @@ export async function getPinnedNotes(notebookId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent notes (notes modified in the last 7 days)
|
||||
// Get recent notes (notes modified in the last 7 days)
|
||||
export async function getRecentNotes(limit: number = 3) {
|
||||
const session = await auth();
|
||||
@@ -824,7 +892,8 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
where: {
|
||||
userId: userId,
|
||||
contentUpdatedAt: { gte: sevenDaysAgo },
|
||||
isArchived: false
|
||||
isArchived: false,
|
||||
dismissedFromRecent: false // Filter out dismissed notes
|
||||
},
|
||||
orderBy: { contentUpdatedAt: 'desc' },
|
||||
take: limit
|
||||
@@ -837,6 +906,25 @@ export async function getRecentNotes(limit: number = 3) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dismiss a note from Recent section
|
||||
export async function dismissFromRecent(id: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||||
|
||||
try {
|
||||
await prisma.note.update({
|
||||
where: { id, userId: session.user.id },
|
||||
data: { dismissedFromRecent: true }
|
||||
})
|
||||
|
||||
// revalidatePath('/') // Removed to prevent immediate refill of the list
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error dismissing note from recent:', error)
|
||||
throw new Error('Failed to dismiss note')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNoteById(noteId: string) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import { revalidatePath, updateTag } from 'next/cache'
|
||||
|
||||
export type UserSettingsData = {
|
||||
theme?: 'light' | 'dark' | 'auto' | 'sepia' | 'midnight' | 'blue'
|
||||
@@ -28,7 +28,7 @@ export async function updateUserSettings(settings: UserSettingsData) {
|
||||
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
revalidateTag('user-settings')
|
||||
updateTag('user-settings')
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function GET(
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch label' },
|
||||
{ status: 500 }
|
||||
@@ -148,6 +149,7 @@ export async function PUT(
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update label' },
|
||||
{ status: 500 }
|
||||
@@ -239,6 +241,7 @@ export async function DELETE(
|
||||
message: `Label "${label.name}" deleted successfully`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete label' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function GET(request: NextRequest) {
|
||||
data: labels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching labels:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch labels' },
|
||||
{ status: 500 }
|
||||
@@ -121,6 +122,7 @@ export async function POST(request: NextRequest) {
|
||||
data: label
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating label:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create label' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -65,6 +65,7 @@ export async function PATCH(
|
||||
notesCount: notebook._count.notes
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update notebook' },
|
||||
{ status: 500 }
|
||||
@@ -125,6 +126,7 @@ export async function DELETE(
|
||||
labelsCount: notebook._count.labels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete notebook' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function GET(request: NextRequest) {
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching notebooks:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notebooks' },
|
||||
{ status: 500 }
|
||||
@@ -94,6 +95,7 @@ export async function POST(request: NextRequest) {
|
||||
notesCount: notebook._count.notes
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating notebook:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create notebook' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
// Helper to parse JSON fields
|
||||
function parseNote(dbNote: any) {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
}
|
||||
}
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
|
||||
// GET /api/notes/[id] - Get a single note
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const note = await prisma.note.findUnique({
|
||||
@@ -28,11 +29,19 @@ export async function GET(
|
||||
)
|
||||
}
|
||||
|
||||
if (note.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch note' },
|
||||
{ status: 500 }
|
||||
@@ -45,12 +54,38 @@ export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const updateData: any = { ...body }
|
||||
|
||||
// Stringify JSON fields if they exist
|
||||
if ('checkItems' in body) {
|
||||
updateData.checkItems = body.checkItems ? JSON.stringify(body.checkItems) : null
|
||||
}
|
||||
@@ -69,6 +104,7 @@ export async function PUT(
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
@@ -81,8 +117,35 @@ export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
})
|
||||
@@ -92,6 +155,7 @@ export async function DELETE(
|
||||
message: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function GET(req: NextRequest) {
|
||||
userId: session.user.id
|
||||
},
|
||||
include: {
|
||||
labels: {
|
||||
labelRelations: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
@@ -84,7 +84,6 @@ export async function GET(req: NextRequest) {
|
||||
notebooks: notebooks.map(notebook => ({
|
||||
id: notebook.id,
|
||||
name: notebook.name,
|
||||
description: notebook.description,
|
||||
noteCount: notebook.notes.length
|
||||
})),
|
||||
notes: notes.map(note => ({
|
||||
@@ -95,7 +94,7 @@ export async function GET(req: NextRequest) {
|
||||
updatedAt: note.updatedAt,
|
||||
isPinned: note.isPinned,
|
||||
notebookId: note.notebookId,
|
||||
labels: note.labels.map(label => ({
|
||||
labelRelations: note.labelRelations.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name
|
||||
}))
|
||||
|
||||
@@ -92,8 +92,7 @@ export async function POST(req: NextRequest) {
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
name: notebook.name,
|
||||
description: notebook.description || null,
|
||||
position: 0
|
||||
order: 0
|
||||
}
|
||||
})
|
||||
newNotebookId = created.id
|
||||
@@ -129,7 +128,7 @@ export async function POST(req: NextRequest) {
|
||||
content: note.content,
|
||||
isPinned: note.isPinned || false,
|
||||
notebookId: mappedNotebookId,
|
||||
labels: {
|
||||
labelRelations: {
|
||||
connect: labels.map(label => ({ id: label.id }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { CheckItem } from '@/lib/types'
|
||||
|
||||
// Helper to parse JSON fields
|
||||
function parseNote(dbNote: any) {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
}
|
||||
}
|
||||
import { auth } from '@/auth'
|
||||
import { parseNote } from '@/lib/utils'
|
||||
|
||||
// GET /api/notes - Get all notes
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const includeArchived = searchParams.get('archived') === 'true'
|
||||
const search = searchParams.get('search')
|
||||
|
||||
let where: any = {}
|
||||
let where: any = {
|
||||
userId: session.user.id
|
||||
}
|
||||
|
||||
if (!includeArchived) {
|
||||
where.isArchived = false
|
||||
@@ -46,6 +47,7 @@ export async function GET(request: NextRequest) {
|
||||
data: notes.map(parseNote)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching notes:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch notes' },
|
||||
{ status: 500 }
|
||||
@@ -55,6 +57,14 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// POST /api/notes - Create a new note
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title, content, color, type, checkItems, labels, images } = body
|
||||
@@ -68,6 +78,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
title: title || null,
|
||||
content: content || '',
|
||||
color: color || 'default',
|
||||
@@ -83,6 +94,7 @@ export async function POST(request: NextRequest) {
|
||||
data: parseNote(note)
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create note' },
|
||||
{ status: 500 }
|
||||
@@ -92,6 +104,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// PUT /api/notes - Update an existing note
|
||||
export async function PUT(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { id, title, content, color, type, checkItems, labels, isPinned, isArchived, images } = body
|
||||
@@ -103,6 +123,24 @@ export async function PUT(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
|
||||
if (title !== undefined) updateData.title = title
|
||||
@@ -125,6 +163,7 @@ export async function PUT(request: NextRequest) {
|
||||
data: parseNote(note)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to update note' },
|
||||
{ status: 500 }
|
||||
@@ -134,6 +173,14 @@ export async function PUT(request: NextRequest) {
|
||||
|
||||
// DELETE /api/notes?id=xxx - Delete a note
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const id = searchParams.get('id')
|
||||
@@ -145,6 +192,24 @@ export async function DELETE(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const existingNote = await prisma.note.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingNote) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Note not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
if (existingNote.userId !== session.user.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Forbidden' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.note.delete({
|
||||
where: { id }
|
||||
})
|
||||
@@ -154,6 +219,7 @@ export async function DELETE(request: NextRequest) {
|
||||
message: 'Note deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error deleting note:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to delete note' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -3,6 +3,11 @@ import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "@/components/ui/toast";
|
||||
import { SessionProviderWrapper } from "@/components/session-provider-wrapper";
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||
import { getThemeScript } from "@/lib/theme-script";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -27,24 +32,6 @@ export const viewport: Viewport = {
|
||||
themeColor: "#f59e0b",
|
||||
};
|
||||
|
||||
|
||||
|
||||
import { getAISettings } from "@/app/actions/ai-settings";
|
||||
import { getUserSettings } from "@/app/actions/user-settings";
|
||||
import { ThemeInitializer } from "@/components/theme-initializer";
|
||||
|
||||
// ... existing imports
|
||||
|
||||
import { DebugTheme } from "@/components/debug-theme";
|
||||
|
||||
// ...
|
||||
|
||||
import { getThemeScript } from "@/lib/theme-script";
|
||||
|
||||
// ...
|
||||
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
|
||||
9
keep-notes/check-reminders.ts
Normal file
9
keep-notes/check-reminders.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
async function main() {
|
||||
const users = await prisma.user.findMany({ select: { email: true }, take: 3 })
|
||||
console.log('Users:', JSON.stringify(users))
|
||||
const notes = await prisma.note.findMany({ where: { reminder: { not: null } }, select: { id: true, title: true, reminder: true, isReminderDone: true }, take: 5 })
|
||||
console.log('Notes avec rappels:', JSON.stringify(notes, null, 2))
|
||||
}
|
||||
main().catch(console.error).finally(() => prisma.$disconnect())
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface MetricItem {
|
||||
title: string
|
||||
@@ -19,6 +20,8 @@ export interface AdminMetricsProps {
|
||||
}
|
||||
|
||||
export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -52,7 +55,7 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
|
||||
{metric.trend.isPositive ? '↑' : '↓'} {Math.abs(metric.trend.value)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
vs last period
|
||||
{t('admin.metrics.vsLastPeriod')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,35 +4,36 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface AdminSidebarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
title: string
|
||||
titleKey: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
titleKey: 'admin.sidebar.dashboard',
|
||||
href: '/admin',
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
titleKey: 'admin.sidebar.users',
|
||||
href: '/admin/users',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'AI Management',
|
||||
titleKey: 'admin.sidebar.aiManagement',
|
||||
href: '/admin/ai',
|
||||
icon: <Brain className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
titleKey: 'admin.sidebar.settings',
|
||||
href: '/admin/settings',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
@@ -40,6 +41,7 @@ const navItems: NavItem[] = [
|
||||
|
||||
export function AdminSidebar({ className }: AdminSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -51,7 +53,7 @@ export function AdminSidebar({ className }: AdminSidebarProps) {
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href))
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
@@ -65,7 +67,7 @@ export function AdminSidebar({ className }: AdminSidebarProps) {
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.titleKey)}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function AutoLabelSuggestionDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch label suggestions:', error)
|
||||
toast.error('Failed to fetch label suggestions')
|
||||
toast.error(t('ai.autoLabels.error'))
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -95,7 +95,7 @@ export function AutoLabelSuggestionDialog({
|
||||
|
||||
const handleCreateLabels = async () => {
|
||||
if (!suggestions || selectedLabels.size === 0) {
|
||||
toast.error('No labels selected')
|
||||
toast.error(t('ai.autoLabels.noLabelsSelected'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,18 +114,15 @@ export function AutoLabelSuggestionDialog({
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
|
||||
`${data.data.createdCount} labels created successfully`
|
||||
)
|
||||
toast.success(t('ai.autoLabels.created', { count: data.data.createdCount }))
|
||||
onLabelsCreated()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to create labels')
|
||||
toast.error(data.error || t('ai.autoLabels.error'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create labels:', error)
|
||||
toast.error('Failed to create labels')
|
||||
toast.error(t('ai.autoLabels.error'))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -188,7 +185,7 @@ export function AutoLabelSuggestionDialog({
|
||||
{t('ai.autoLabels.notesCount', { count: label.count })}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{Math.round(label.confidence * 100)}% confidence
|
||||
{Math.round(label.confidence * 100)}% {t('notebook.confidence')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,11 +56,11 @@ export function BatchOrganizationDialog({
|
||||
})
|
||||
setSelectedNotes(allNoteIds)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to create organization plan')
|
||||
toast.error(data.error || t('ai.batchOrganization.error'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create organization plan:', error)
|
||||
toast.error('Failed to create organization plan')
|
||||
toast.error(t('ai.batchOrganization.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function BatchOrganizationDialog({
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!plan || selectedNotes.size === 0) {
|
||||
toast.error('No notes selected')
|
||||
toast.error(t('ai.batchOrganization.noNotesSelected'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,18 +127,15 @@ export function BatchOrganizationDialog({
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
|
||||
`${data.data.movedCount} notes moved successfully`
|
||||
)
|
||||
toast.success(t('ai.batchOrganization.success', { count: data.data.movedCount }))
|
||||
onNotesMoved()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to apply organization plan')
|
||||
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply organization plan:', error)
|
||||
toast.error('Failed to apply organization plan')
|
||||
toast.error(t('ai.batchOrganization.applyFailed'))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
@@ -222,7 +219,7 @@ export function BatchOrganizationDialog({
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => toggleNotebookSelection(notebook)}
|
||||
aria-label={`Select all notes in ${notebook.notebookName}`}
|
||||
aria-label={t('ai.batchOrganization.selectAllIn', { notebook: notebook.notebookName })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{notebook.notebookIcon}</span>
|
||||
@@ -247,7 +244,7 @@ export function BatchOrganizationDialog({
|
||||
<Checkbox
|
||||
checked={selectedNotes.has(note.noteId)}
|
||||
onCheckedChange={() => toggleNoteSelection(note.noteId)}
|
||||
aria-label={`Select note: ${note.title || 'Untitled'}`}
|
||||
aria-label={t('ai.batchOrganization.selectNote', { title: note.title || t('notes.untitled') })}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
@@ -258,7 +255,7 @@ export function BatchOrganizationDialog({
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{Math.round(note.confidence * 100)}% confidence
|
||||
{Math.round(note.confidence * 100)}% {t('notebook.confidence')}
|
||||
</span>
|
||||
{note.reason && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -114,7 +114,7 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Q4 Marketing Strategy"
|
||||
placeholder={t('notebook.namePlaceholder')}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -61,13 +61,13 @@ export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNoteboo
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
{t('notebook.name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Notebook"
|
||||
placeholder={t('notebook.myNotebook')}
|
||||
className="col-span-3"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -85,7 +85,7 @@ export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNoteboo
|
||||
type="submit"
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : t('general.confirm')}
|
||||
{isSubmitting ? t('notebook.saving') : t('general.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -119,7 +119,13 @@ export function Header({
|
||||
// Skip if search hasn't changed or if we already pushed this value
|
||||
if (debouncedSearchQuery === lastPushedSearch.current) return
|
||||
|
||||
// Build new params preserving other filters
|
||||
// Only trigger search navigation from the home page
|
||||
if (pathname !== '/') {
|
||||
lastPushedSearch.current = debouncedSearchQuery
|
||||
return
|
||||
}
|
||||
|
||||
// Build new params preserving other filters (notebook, labels, etc.)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
params.set('search', debouncedSearchQuery)
|
||||
@@ -132,6 +138,7 @@ export function Header({
|
||||
// Mark as pushed before calling router.push to prevent loops
|
||||
lastPushedSearch.current = debouncedSearchQuery
|
||||
router.push(newUrl)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchQuery])
|
||||
|
||||
// Handle semantic search button click
|
||||
@@ -303,15 +310,15 @@ export function Header({
|
||||
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
|
||||
</div>
|
||||
|
||||
{/* Search Bar - Style Keep */}
|
||||
{/* Search Bar */}
|
||||
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
|
||||
<div className="flex w-full flex-1 items-stretch rounded-xl h-full bg-slate-100 dark:bg-slate-800 focus-within:ring-2 focus-within:ring-primary/20 transition-all">
|
||||
<div className="text-slate-500 dark:text-slate-400 flex items-center justify-center pl-4">
|
||||
<Search className="w-5 h-5" />
|
||||
<div className="flex w-full flex-1 items-stretch rounded-full h-full bg-slate-100 dark:bg-slate-800 border border-transparent focus-within:bg-white dark:focus-within:bg-slate-700 focus-within:border-primary/30 focus-within:shadow-md transition-all duration-200">
|
||||
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-500 dark:placeholder:text-slate-400 px-3 text-sm font-medium focus:ring-0"
|
||||
placeholder={t('search.placeholder') || "Search notes, labels, and more..."}
|
||||
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
|
||||
placeholder={t('search.placeholder') || "Rechercher..."}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
|
||||
@@ -76,11 +76,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
})
|
||||
|
||||
// Show success message and open modal
|
||||
toast.success('Opening connection...')
|
||||
toast.success(t('toast.openingConnection'))
|
||||
setShowModal(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to view connection:', error)
|
||||
toast.error('Failed to open connection')
|
||||
toast.error(t('toast.openConnectionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,16 +100,16 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
// Show feedback toast
|
||||
if (feedback === 'thumbs_up') {
|
||||
toast.success('Thanks for your feedback!')
|
||||
toast.success(t('toast.thanksFeedback'))
|
||||
} else {
|
||||
toast.success('Thanks! We\'ll use this to improve.')
|
||||
toast.success(t('toast.thanksFeedbackImproving'))
|
||||
}
|
||||
|
||||
// Dismiss notification
|
||||
setIsDismissed(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to submit feedback:', error)
|
||||
toast.error('Failed to submit feedback')
|
||||
toast.error(t('toast.feedbackFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
}
|
||||
|
||||
// Calculate values for both notification and modal
|
||||
const note1Title = insight.note1.title || 'Untitled'
|
||||
const note2Title = insight.note2.title || 'Untitled'
|
||||
const note1Title = insight.note1.title || t('notification.untitled')
|
||||
const note2Title = insight.note2.title || t('notification.untitled')
|
||||
const similarityPercentage = Math.round(insight.similarityScore * 100)
|
||||
|
||||
// Render modal if requested
|
||||
@@ -286,7 +286,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
{note2Title}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{similarityPercentage}% match
|
||||
{t('memoryEcho.match', { percentage: similarityPercentage })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
|
||||
// Reminder state
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
||||
note.reminder ? new Date(note.reminder as unknown as string) : null
|
||||
)
|
||||
|
||||
// Link state
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||
@@ -325,12 +327,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content, option: 'clarify' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to clarify')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Clarify error:', error)
|
||||
toast.error(t('ai.reformulationFailed'))
|
||||
toast.error(t('notes.clarifyFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
@@ -351,12 +353,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content, option: 'shorten' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to shorten')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Shorten error:', error)
|
||||
toast.error(t('ai.reformulationFailed'))
|
||||
toast.error(t('notes.shortenFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
@@ -377,12 +379,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content, option: 'improve' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to improve')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Improve error:', error)
|
||||
toast.error(t('ai.reformulationFailed'))
|
||||
toast.error(t('notes.improveFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
@@ -408,7 +410,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to transform')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
||||
|
||||
// Set the transformed markdown content and enable markdown mode
|
||||
setContent(data.transformedText)
|
||||
@@ -440,18 +442,28 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
}
|
||||
|
||||
const handleReminderSave = (date: Date) => {
|
||||
const handleReminderSave = async (date: Date) => {
|
||||
if (date < new Date()) {
|
||||
toast.error(t('notes.reminderPastError'))
|
||||
return
|
||||
}
|
||||
setCurrentReminder(date)
|
||||
toast.success(t('notes.reminderSet', { date: date.toLocaleString() }))
|
||||
try {
|
||||
await updateNote(note.id, { reminder: date })
|
||||
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
||||
} catch {
|
||||
toast.error(t('notebook.savingReminder'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveReminder = () => {
|
||||
const handleRemoveReminder = async () => {
|
||||
setCurrentReminder(null)
|
||||
toast.success(t('notes.reminderRemoved'))
|
||||
try {
|
||||
await updateNote(note.id, { reminder: null })
|
||||
toast.success(t('notes.reminderRemoved'))
|
||||
} catch {
|
||||
toast.error(t('notebook.removingReminder'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog'
|
||||
import { Loader2, FileText, RefreshCw } from 'lucide-react'
|
||||
import { Loader2, FileText, RefreshCw, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { NotebookSummary } from '@/lib/ai/services'
|
||||
@@ -81,10 +81,79 @@ export function NotebookSummaryDialog({
|
||||
setRegenerating(false)
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
if (!summary) return
|
||||
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) return
|
||||
|
||||
const date = new Date(summary.generatedAt).toLocaleString()
|
||||
const labels = summary.stats.labelsUsed.length > 0
|
||||
? `<p><strong>${t('notebook.labels')}</strong> ${summary.stats.labelsUsed.join(', ')}</p>`
|
||||
: ''
|
||||
|
||||
printWindow.document.write(`<!DOCTYPE html>
|
||||
<html lang="${document.documentElement.lang || 'en'}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>${t('notebook.pdfTitle', { name: summary.notebookName })}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: Georgia, 'Times New Roman', serif; font-size: 13pt; line-height: 1.7; color: #111; padding: 2.5cm 3cm; max-width: 800px; margin: 0 auto; }
|
||||
h1 { font-size: 20pt; font-weight: bold; margin-bottom: 0.25em; }
|
||||
.meta { font-size: 10pt; color: #666; margin-bottom: 1.5em; border-bottom: 1px solid #ddd; padding-bottom: 0.75em; }
|
||||
.meta p { margin: 0.2em 0; }
|
||||
.content h1, .content h2, .content h3 { margin-top: 1.2em; margin-bottom: 0.4em; font-family: sans-serif; }
|
||||
.content h2 { font-size: 15pt; }
|
||||
.content h3 { font-size: 13pt; }
|
||||
.content p { margin-bottom: 0.8em; }
|
||||
.content ul, .content ol { margin-left: 1.5em; margin-bottom: 0.8em; }
|
||||
.content li { margin-bottom: 0.3em; }
|
||||
.content strong { font-weight: bold; }
|
||||
.content em { font-style: italic; }
|
||||
.content code { font-family: monospace; background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
|
||||
.content blockquote { border-left: 3px solid #ccc; padding-left: 1em; color: #555; margin: 0.8em 0; }
|
||||
@media print {
|
||||
body { padding: 1cm 1.5cm; }
|
||||
@page { margin: 1.5cm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${t('notebook.pdfTitle', { name: summary.notebookName })}</h1>
|
||||
<div class="meta">
|
||||
<p><strong>${t('notebook.pdfNotesLabel')}</strong> ${summary.stats.totalNotes}</p>
|
||||
${labels}
|
||||
<p><strong>${t('notebook.pdfGeneratedOn')}</strong> ${date}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
${summary.summary
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
.replace(/^[-*] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/^(?!<[hHuUoOblp])(.+)$/gm, '<p>$1</p>')}
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
setTimeout(() => {
|
||||
printWindow.print()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="overflow-y-auto" style={{ maxWidth: 'min(48rem, 95vw)', maxHeight: '90vh' }}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{t('notebook.generating')}</DialogTitle>
|
||||
<DialogDescription>{t('notebook.generatingDescription') || 'Please wait...'}</DialogDescription>
|
||||
@@ -106,56 +175,69 @@ export function NotebookSummaryDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between gap-4">
|
||||
<DialogContent
|
||||
className="flex flex-col overflow-hidden p-0"
|
||||
style={{ maxWidth: 'min(64rem, 95vw)', height: '90vh' }}
|
||||
>
|
||||
{/* En-tête fixe */}
|
||||
<div className="flex-shrink-0 px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{t('notebook.summary')}
|
||||
<span className="text-lg font-semibold">{t('notebook.summary')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
{regenerating
|
||||
? (t('ai.notebookSummary.regenerating') || 'Regenerating...')
|
||||
: (t('ai.notebookSummary.regenerate') || 'Regenerate')}
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExportPDF} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('ai.notebookSummary.exportPDF') || 'Exporter PDF'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
{regenerating
|
||||
? (t('ai.notebookSummary.regenerating') || 'Regenerating...')
|
||||
: (t('ai.notebookSummary.regenerate') || 'Regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('notebook.summaryDescription', {
|
||||
notebook: summary.notebookName,
|
||||
count: summary.stats.totalNotes,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{summary.stats.totalNotes} {summary.stats.totalNotes === 1 ? 'note' : 'notes'}
|
||||
</span>
|
||||
</div>
|
||||
{summary.stats.labelsUsed.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Labels:</span>
|
||||
<span className="text-sm">{summary.stats.labelsUsed.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{new Date(summary.generatedAt).toLocaleString()}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Content */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{summary.summary}</ReactMarkdown>
|
||||
{/* Zone scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{t('ai.autoLabels.notesCount', { count: summary.stats.totalNotes })}
|
||||
</span>
|
||||
</div>
|
||||
{summary.stats.labelsUsed.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('notebook.labels')}</span>
|
||||
<span className="text-sm">{summary.stats.labelsUsed.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{new Date(summary.generatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu Markdown */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{summary.summary}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function NotificationPanel() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
||||
<span className="font-semibold text-sm">{t('nav.aiSettings')}</span>
|
||||
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
|
||||
@@ -166,7 +166,7 @@ export function NotificationPanel() {
|
||||
{request.sharer.name || request.sharer.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
shared "{request.note.title || 'Untitled'}"
|
||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
|
||||
232
keep-notes/components/reminders-page.tsx
Normal file
232
keep-notes/components/reminders-page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Bell, BellOff, CheckCircle2, Circle, Clock, AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { toggleReminderDone } from '@/app/actions/notes'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface RemindersPageProps {
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
function formatReminderDate(date: Date | string, t: (key: string, params?: Record<string, string | number>) => string, locale = 'fr-FR'): string {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const noteDay = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
|
||||
const timeStr = d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
if (noteDay.getTime() === today.getTime()) return t('reminders.todayAt', { time: timeStr })
|
||||
if (noteDay.getTime() === tomorrow.getTime()) return t('reminders.tomorrowAt', { time: timeStr })
|
||||
|
||||
return d.toLocaleDateString(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function ReminderCard({ note, onToggleDone, t }: { note: Note; onToggleDone: (id: string, done: boolean) => void; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const now = new Date()
|
||||
const reminderDate = note.reminder ? new Date(note.reminder) : null
|
||||
const isOverdue = reminderDate && reminderDate < now && !note.isReminderDone
|
||||
const isDone = note.isReminderDone
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-start gap-4 rounded-2xl border p-4 transition-all duration-200',
|
||||
isDone
|
||||
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 opacity-60'
|
||||
: isOverdue
|
||||
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-[#1e2128] hover:shadow-md'
|
||||
)}
|
||||
>
|
||||
{/* Done toggle */}
|
||||
<button
|
||||
onClick={() => onToggleDone(note.id, !isDone)}
|
||||
className={cn(
|
||||
'mt-0.5 flex-none transition-colors',
|
||||
isDone
|
||||
? 'text-green-500 hover:text-slate-400'
|
||||
: 'text-slate-300 hover:text-green-500 dark:text-slate-600'
|
||||
)}
|
||||
title={isDone ? t('reminders.markUndone') : t('reminders.markDone')}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{note.title && (
|
||||
<p className={cn('font-semibold text-slate-900 dark:text-white truncate', isDone && 'line-through opacity-60')}>
|
||||
{note.title}
|
||||
</p>
|
||||
)}
|
||||
<p className={cn('text-sm text-slate-600 dark:text-slate-300 line-clamp-2 mt-0.5', isDone && 'line-through opacity-60')}>
|
||||
{note.content}
|
||||
</p>
|
||||
|
||||
{/* Reminder date badge */}
|
||||
{reminderDate && (
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1.5 mt-2 px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
isDone
|
||||
? 'bg-slate-100 dark:bg-slate-700 text-slate-500'
|
||||
: isOverdue
|
||||
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-primary/10 text-primary'
|
||||
)}>
|
||||
{isOverdue && !isDone ? (
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<Clock className="w-3 h-3" />
|
||||
)}
|
||||
{formatReminderDate(reminderDate, t)}
|
||||
{note.reminderRecurrence && (
|
||||
<span className="ml-1 opacity-70">· {note.reminderRecurrence}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ icon: Icon, label, count, color }: { icon: any; label: string; count: number; color: string }) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 mb-3', color)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider">{label}</h2>
|
||||
<span className="ml-auto text-xs font-medium bg-current/10 px-2 py-0.5 rounded-full opacity-70">{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemindersPage({ notes: initialNotes }: RemindersPageProps) {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [notes, setNotes] = useState(initialNotes)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const upcoming = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) >= now)
|
||||
const overdue = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) < now)
|
||||
const done = notes.filter(n => n.isReminderDone)
|
||||
|
||||
const handleToggleDone = (noteId: string, newDone: boolean) => {
|
||||
// Optimistic update
|
||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, isReminderDone: newDone } : n))
|
||||
|
||||
startTransition(async () => {
|
||||
await toggleReminderDone(noteId, newDone)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-12 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-primary" />
|
||||
{t('reminders.title') || 'Rappels'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center text-slate-500 dark:text-slate-400">
|
||||
<div className="bg-slate-100 dark:bg-slate-800 p-6 rounded-full mb-4">
|
||||
<BellOff className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2 text-slate-700 dark:text-slate-300">
|
||||
{t('reminders.empty') || 'Aucun rappel'}
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm opacity-80">
|
||||
{t('reminders.emptyDescription') || 'Ajoutez un rappel à une note pour le retrouver ici.'}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-primary" />
|
||||
{t('reminders.title') || 'Rappels'}
|
||||
</h1>
|
||||
{isPending && (
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* En retard */}
|
||||
{overdue.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={AlertCircle}
|
||||
label={t('reminders.overdue') || 'En retard'}
|
||||
count={overdue.length}
|
||||
color="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{overdue.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* À venir */}
|
||||
{upcoming.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={Clock}
|
||||
label={t('reminders.upcoming') || 'À venir'}
|
||||
count={upcoming.length}
|
||||
color="text-primary"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{upcoming.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Terminés */}
|
||||
{done.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={CheckCircle2}
|
||||
label={t('reminders.done') || 'Terminés'}
|
||||
count={done.length}
|
||||
color="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{done.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface Section {
|
||||
id: string
|
||||
@@ -23,12 +24,15 @@ interface SettingsSearchProps {
|
||||
export function SettingsSearch({
|
||||
sections,
|
||||
onFilter,
|
||||
placeholder = 'Search settings...',
|
||||
placeholder,
|
||||
className
|
||||
}: SettingsSearchProps) {
|
||||
const { t } = useLanguage()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
|
||||
|
||||
const searchPlaceholder = placeholder || t('settings.searchNoResults') || 'Search settings...'
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setFilteredSections(sections)
|
||||
@@ -77,7 +81,7 @@ export function SettingsSearch({
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
placeholder={searchPlaceholder}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -86,14 +90,14 @@ export function SettingsSearch({
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Clear search"
|
||||
aria-label={t('search.placeholder')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{isEmptySearch && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 p-2 bg-white rounded-lg shadow-lg border z-50">
|
||||
<p className="text-sm text-gray-600">No settings found</p>
|
||||
<p className="text-sm text-gray-600">{t('settings.searchNoResults')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { deepEqual } from '@/lib/utils'
|
||||
|
||||
export interface UndoRedoState<T> {
|
||||
past: T[]
|
||||
@@ -42,7 +43,7 @@ export function useUndoRedo<T>(initialState: T): UseUndoRedoReturn<T> {
|
||||
: newState
|
||||
|
||||
// Don't add to history if state hasn't changed
|
||||
if (JSON.stringify(resolvedNewState) === JSON.stringify(currentHistory.present)) {
|
||||
if (deepEqual(resolvedNewState, currentHistory.present)) {
|
||||
return currentHistory
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,6 @@ export function getEmbeddingsProvider(config?: Record<string, string>): AIProvid
|
||||
return getProviderInstance(provider, config || {}, modelName, embeddingModelName);
|
||||
}
|
||||
|
||||
// Legacy function for backward compatibility
|
||||
export function getAIProvider(config?: Record<string, string>): AIProvider {
|
||||
return getTagsProvider(config);
|
||||
return getEmbeddingsProvider(config);
|
||||
}
|
||||
|
||||
37
keep-notes/lib/connections-cache.ts
Normal file
37
keep-notes/lib/connections-cache.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Cache with TTL for 15 minutes
|
||||
const CACHE_TTL = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
interface CacheEntry {
|
||||
count: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
|
||||
export async function getConnectionsCount(noteId: string): Promise<number> {
|
||||
const now = Date.now()
|
||||
const cached = cache.get(noteId)
|
||||
|
||||
if (cached && (now - cached.timestamp) < CACHE_TTL) {
|
||||
return cached.count
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=1`)
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch connections')
|
||||
}
|
||||
const data = await res.json()
|
||||
const count = data.pagination?.total || 0
|
||||
|
||||
// Update cache for future calls
|
||||
if (count > 0) {
|
||||
cache.set(noteId, { count, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
return count
|
||||
} catch (error) {
|
||||
console.error('[ConnectionsCache] Failed to fetch connections:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -57,9 +57,11 @@ export interface Note {
|
||||
images: string[] | null;
|
||||
links: LinkMetadata[] | null;
|
||||
reminder: Date | null;
|
||||
isReminderDone: boolean;
|
||||
reminderRecurrence: string | null;
|
||||
reminderLocation: string | null;
|
||||
isMarkdown: boolean;
|
||||
dismissedFromRecent?: boolean;
|
||||
size: NoteSize;
|
||||
order: number;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -1,11 +1,55 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { LABEL_COLORS, LabelColorName, QueryType } from "./types"
|
||||
import { LABEL_COLORS, LabelColorName, QueryType, Note } from "./types"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality check for two values
|
||||
* More performant than JSON.stringify for comparison
|
||||
*/
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true
|
||||
if (a === null || b === null) return a === b
|
||||
if (typeof a !== typeof b) return false
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((item, index) => deepEqual(item, b[index]))
|
||||
}
|
||||
|
||||
if (typeof a === 'object' && typeof b === 'object') {
|
||||
const keysA = Object.keys(a as Record<string, unknown>)
|
||||
const keysB = Object.keys(b as Record<string, unknown>)
|
||||
if (keysA.length !== keysB.length) return false
|
||||
return keysA.every(key => deepEqual(
|
||||
(a as Record<string, unknown>)[key],
|
||||
(b as Record<string, unknown>)[key]
|
||||
))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a database note object into a typed Note
|
||||
* Handles JSON string fields that are stored in the database
|
||||
*/
|
||||
export function parseNote(dbNote: any): Note {
|
||||
return {
|
||||
...dbNote,
|
||||
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
||||
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
||||
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
||||
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
||||
embedding: dbNote.embedding ? JSON.parse(dbNote.embedding) : null,
|
||||
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
||||
size: dbNote.size || 'small',
|
||||
}
|
||||
}
|
||||
|
||||
export function getHashColor(name: string): LabelColorName {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
"connections": "Connections",
|
||||
"connection": "connection",
|
||||
"connectionsBadge": "{count} connection{plural}",
|
||||
"match": "{percentage}% match",
|
||||
"fused": "Fused",
|
||||
"clickToView": "Click to view note →",
|
||||
"overlay": {
|
||||
@@ -385,6 +386,11 @@
|
||||
"unknownDate": "Unknown date"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"shared": "shared \"{title}\"",
|
||||
"untitled": "Untitled",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"notes": "Notes",
|
||||
@@ -560,11 +566,26 @@
|
||||
"save": "Set reminder",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Reminders",
|
||||
"empty": "No reminders",
|
||||
"emptyDescription": "Add a reminder to a note to find it here.",
|
||||
"upcoming": "Upcoming",
|
||||
"overdue": "Overdue",
|
||||
"done": "Done",
|
||||
"markDone": "Mark as done",
|
||||
"markUndone": "Mark as undone",
|
||||
"todayAt": "Today at {time}",
|
||||
"tomorrowAt": "Tomorrow at {time}"
|
||||
},
|
||||
"notebook": {
|
||||
"create": "Create Notebook",
|
||||
"createNew": "Create New Notebook",
|
||||
"createDescription": "Start a new collection to organize your notes, ideas, and projects efficiently.",
|
||||
"name": "Notebook Name",
|
||||
"namePlaceholder": "e.g. Q4 Marketing Strategy",
|
||||
"myNotebook": "My Notebook",
|
||||
"saving": "Saving...",
|
||||
"selectIcon": "Icon",
|
||||
"selectColor": "Color",
|
||||
"cancel": "Cancel",
|
||||
@@ -579,7 +600,13 @@
|
||||
"generating": "Generating summary...",
|
||||
"summaryError": "Error generating summary",
|
||||
"labels": "Labels:",
|
||||
"noLabels": "No labels"
|
||||
"noLabels": "No labels",
|
||||
"pdfTitle": "Summary — {name}",
|
||||
"pdfNotesLabel": "Notes:",
|
||||
"pdfGeneratedOn": "Generated on:",
|
||||
"confidence": "confidence",
|
||||
"savingReminder": "Failed to save reminder",
|
||||
"removingReminder": "Failed to remove reminder"
|
||||
},
|
||||
"notebookSuggestion": {
|
||||
"title": "Move to {icon} {name}?",
|
||||
@@ -689,12 +716,27 @@
|
||||
"embeddingsTestTitle": "Embeddings Test",
|
||||
"embeddingsTestDescription": "Test the AI provider responsible for semantic search embeddings",
|
||||
"howItWorksTitle": "How Testing Works",
|
||||
"tagsGenerationTest": "🏷️ Tags Generation Test:",
|
||||
"tagsStep1": "Sends a sample note to the AI provider",
|
||||
"tagsStep2": "Requests 3-5 relevant tags based on the content",
|
||||
"tagsStep3": "Displays the generated tags with confidence scores",
|
||||
"tagsStep4": "Measures response time",
|
||||
"embeddingsTestLabel": "🔍 Embeddings Test:",
|
||||
"embeddingsStep1": "Sends a sample text to the embedding provider",
|
||||
"embeddingsStep2": "Generates a vector representation (list of numbers)",
|
||||
"embeddingsStep3": "Displays embedding dimensions and sample values",
|
||||
"embeddingsStep4": "Verifies the vector is valid and properly formatted",
|
||||
"tipContent": "You can use different providers for tags and embeddings! For example, use Ollama (free) for tags and OpenAI (best quality) for embeddings to optimize costs and performance.",
|
||||
"provider": "Provider:",
|
||||
"model": "Model:",
|
||||
"testing": "Testing...",
|
||||
"runTest": "Run Test",
|
||||
"testPassed": "Test Passed",
|
||||
"testFailed": "Test Failed",
|
||||
"testSuccessToast": "{type} Test Successful!",
|
||||
"testFailedToast": "{type} Test Failed",
|
||||
"testingType": "Testing {type}...",
|
||||
"technicalDetails": "Technical details",
|
||||
"responseTime": "Response time: {time}ms",
|
||||
"generatedTags": "Generated Tags:",
|
||||
"embeddingDimensions": "Embedding Dimensions:",
|
||||
@@ -704,6 +746,15 @@
|
||||
"testError": "Test Error: {error}",
|
||||
"tipTitle": "Tip:",
|
||||
"tipDescription": "Use the AI Test Panel to diagnose configuration issues before testing."
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"users": "Users",
|
||||
"aiManagement": "AI Management",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs last period"
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
|
||||
@@ -80,6 +80,17 @@
|
||||
"first5Values": "5 premières valeurs :",
|
||||
"generatedTags": "Étiquettes générées :",
|
||||
"howItWorksTitle": "Fonctionnement des tests",
|
||||
"tagsGenerationTest": "🏷️ Test de génération d'étiquettes :",
|
||||
"tagsStep1": "Envoie une note exemple au fournisseur IA",
|
||||
"tagsStep2": "Demande 3-5 étiquettes pertinentes basées sur le contenu",
|
||||
"tagsStep3": "Affiche les étiquettes générées avec les scores de confiance",
|
||||
"tagsStep4": "Mesure le temps de réponse",
|
||||
"embeddingsTestLabel": "🔍 Test d'embeddings :",
|
||||
"embeddingsStep1": "Envoie un texte exemple au fournisseur d'embeddings",
|
||||
"embeddingsStep2": "Génère une représentation vectorielle (liste de nombres)",
|
||||
"embeddingsStep3": "Affiche les dimensions de l'embedding et des exemples de valeurs",
|
||||
"embeddingsStep4": "Vérifie que le vecteur est valide et correctement formaté",
|
||||
"tipContent": "Vous pouvez utiliser différents fournisseurs pour les étiquettes et les embeddings ! Par exemple, utilisez Ollama (gratuit) pour les étiquettes et OpenAI (meilleure qualité) pour les embeddings afin d'optimiser les coûts et les performances.",
|
||||
"model": "Modèle :",
|
||||
"provider": "Fournisseur :",
|
||||
"responseTime": "Temps de réponse : {time}ms",
|
||||
@@ -89,12 +100,25 @@
|
||||
"testError": "Erreur de test : {error}",
|
||||
"testFailed": "Test échoué",
|
||||
"testPassed": "Test réussi",
|
||||
"testSuccessToast": "Test {type} réussi !",
|
||||
"testFailedToast": "Test {type} échoué",
|
||||
"testingType": "Test de {type} en cours...",
|
||||
"technicalDetails": "Détails techniques",
|
||||
"testing": "Test en cours...",
|
||||
"tipDescription": "Utilisez le panneau de test IA pour diagnostiquer les problèmes de configuration avant de tester.",
|
||||
"tipTitle": "Astuce :",
|
||||
"title": "Test des fournisseurs IA",
|
||||
"vectorDimensions": "dimensions vectorielles"
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"users": "Utilisateurs",
|
||||
"aiManagement": "Gestion IA",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs période précédente"
|
||||
},
|
||||
"aiTesting": "Test IA",
|
||||
"security": {
|
||||
"allowPublicRegistration": "Autoriser l'inscription publique",
|
||||
@@ -201,7 +225,8 @@
|
||||
"languageDetected": "Langue détectée",
|
||||
"notebookSummary": {
|
||||
"regenerate": "Régénérer le résumé",
|
||||
"regenerating": "Régénération du résumé..."
|
||||
"regenerating": "Régénération du résumé...",
|
||||
"exportPDF": "Exporter en PDF"
|
||||
},
|
||||
"original": "Original",
|
||||
"poweredByAI": "Propulsé par l'IA",
|
||||
@@ -549,6 +574,7 @@
|
||||
"connection": "connexion",
|
||||
"connections": "Connexions",
|
||||
"connectionsBadge": "{count} connexion{plural}",
|
||||
"match": "{percentage}% correspondance",
|
||||
"title": "💡 J'ai remarqué quelque chose...",
|
||||
"description": "Connexions proactives entre vos notes",
|
||||
"dailyInsight": "Aperçu quotidien de vos notes",
|
||||
@@ -608,6 +634,11 @@
|
||||
"unknownDate": "Date inconnue"
|
||||
}
|
||||
},
|
||||
"notification": {
|
||||
"shared": "a partagé « {title} »",
|
||||
"untitled": "Sans titre",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"nav": {
|
||||
"accountSettings": "Paramètres du compte",
|
||||
"adminDashboard": "Tableau de bord Admin",
|
||||
@@ -657,12 +688,21 @@
|
||||
"generating": "Génération du résumé...",
|
||||
"labels": "Étiquettes :",
|
||||
"name": "Nom du carnet",
|
||||
"namePlaceholder": "ex. Stratégie Marketing Q4",
|
||||
"myNotebook": "Mon carnet",
|
||||
"saving": "Enregistrement...",
|
||||
"noLabels": "Aucune étiquette",
|
||||
"selectColor": "Couleur",
|
||||
"selectIcon": "Icône",
|
||||
"summary": "Résumé du carnet",
|
||||
"summaryDescription": "Générer un résumé alimenté par l'IA de toutes les notes de ce carnet.",
|
||||
"summaryError": "Erreur lors de la génération du résumé"
|
||||
"summaryError": "Erreur lors de la génération du résumé",
|
||||
"pdfTitle": "Résumé — {name}",
|
||||
"pdfNotesLabel": "Notes :",
|
||||
"pdfGeneratedOn": "Généré le :",
|
||||
"confidence": "confiance",
|
||||
"savingReminder": "Erreur lors de la sauvegarde du rappel",
|
||||
"removingReminder": "Erreur lors de la suppression du rappel"
|
||||
},
|
||||
"notebookSuggestion": {
|
||||
"description": "Cette note semble appartenir à ce carnet",
|
||||
@@ -906,6 +946,18 @@
|
||||
"title": "Paramètres",
|
||||
"version": "Version"
|
||||
},
|
||||
"reminders": {
|
||||
"title": "Rappels",
|
||||
"empty": "Aucun rappel",
|
||||
"emptyDescription": "Ajoutez un rappel à une note pour le retrouver ici.",
|
||||
"upcoming": "À venir",
|
||||
"overdue": "En retard",
|
||||
"done": "Terminés",
|
||||
"markDone": "Marquer comme terminé",
|
||||
"markUndone": "Marquer comme non terminé",
|
||||
"todayAt": "Aujourd'hui à {time}",
|
||||
"tomorrowAt": "Demain à {time}"
|
||||
},
|
||||
"sidebar": {
|
||||
"archive": "Archives",
|
||||
"editLabels": "Modifier les étiquettes",
|
||||
|
||||
@@ -11,6 +11,9 @@ const nextConfig: NextConfig = {
|
||||
// Enable standalone output for Docker
|
||||
output: 'standalone',
|
||||
|
||||
// Empty turbopack config to silence Turbopack/webpack conflict warning in Next.js 16
|
||||
turbopack: {},
|
||||
|
||||
// Webpack config (needed for PWA plugin)
|
||||
webpack: (config, { isServer }) => {
|
||||
// Fixes npm packages that depend on `fs` module
|
||||
@@ -30,6 +33,9 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
unoptimized: true, // Required for standalone
|
||||
},
|
||||
|
||||
// Hide the "compiling" indicator as requested by the user
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- main [ref=e4]:
|
||||
- generic [ref=e7]:
|
||||
- heading "Sign in to your account" [level=1] [ref=e8]
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]: Email
|
||||
- textbox "Email" [ref=e13]:
|
||||
- /placeholder: Enter your email address
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e15]: Password
|
||||
- textbox "Password" [ref=e17]:
|
||||
- /placeholder: Enter your password
|
||||
- link "Forgot password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
- button "Sign In" [ref=e20]
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=e27] [cursor=pointer]:
|
||||
- img [ref=e28]
|
||||
- alert [ref=e31]
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
@@ -29,7 +29,7 @@ async function testMasonryLayout() {
|
||||
console.log('📸 Screenshot saved: masonry-before.png');
|
||||
|
||||
// Check DOM for MasonryItem elements
|
||||
const masonryItems = await page.$$eval('.masonry-item', (items) => {
|
||||
const masonryItems = await page.$$eval('.masonry-item', (items: Element[]) => {
|
||||
return items.map(item => ({
|
||||
hasDataSize: item.hasAttribute('data-size'),
|
||||
dataSize: item.getAttribute('data-size'),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -193,6 +193,7 @@ exports.Prisma.NoteScalarFieldEnum = {
|
||||
isPinned: 'isPinned',
|
||||
isArchived: 'isArchived',
|
||||
type: 'type',
|
||||
dismissedFromRecent: 'dismissedFromRecent',
|
||||
checkItems: 'checkItems',
|
||||
labels: 'labels',
|
||||
images: 'images',
|
||||
|
||||
62
keep-notes/prisma/client-generated/index.d.ts
vendored
62
keep-notes/prisma/client-generated/index.d.ts
vendored
@@ -8214,6 +8214,7 @@ export namespace Prisma {
|
||||
isPinned: boolean | null
|
||||
isArchived: boolean | null
|
||||
type: string | null
|
||||
dismissedFromRecent: boolean | null
|
||||
checkItems: string | null
|
||||
labels: string | null
|
||||
images: string | null
|
||||
@@ -8248,6 +8249,7 @@ export namespace Prisma {
|
||||
isPinned: boolean | null
|
||||
isArchived: boolean | null
|
||||
type: string | null
|
||||
dismissedFromRecent: boolean | null
|
||||
checkItems: string | null
|
||||
labels: string | null
|
||||
images: string | null
|
||||
@@ -8282,6 +8284,7 @@ export namespace Prisma {
|
||||
isPinned: number
|
||||
isArchived: number
|
||||
type: number
|
||||
dismissedFromRecent: number
|
||||
checkItems: number
|
||||
labels: number
|
||||
images: number
|
||||
@@ -8330,6 +8333,7 @@ export namespace Prisma {
|
||||
isPinned?: true
|
||||
isArchived?: true
|
||||
type?: true
|
||||
dismissedFromRecent?: true
|
||||
checkItems?: true
|
||||
labels?: true
|
||||
images?: true
|
||||
@@ -8364,6 +8368,7 @@ export namespace Prisma {
|
||||
isPinned?: true
|
||||
isArchived?: true
|
||||
type?: true
|
||||
dismissedFromRecent?: true
|
||||
checkItems?: true
|
||||
labels?: true
|
||||
images?: true
|
||||
@@ -8398,6 +8403,7 @@ export namespace Prisma {
|
||||
isPinned?: true
|
||||
isArchived?: true
|
||||
type?: true
|
||||
dismissedFromRecent?: true
|
||||
checkItems?: true
|
||||
labels?: true
|
||||
images?: true
|
||||
@@ -8519,6 +8525,7 @@ export namespace Prisma {
|
||||
isPinned: boolean
|
||||
isArchived: boolean
|
||||
type: string
|
||||
dismissedFromRecent: boolean
|
||||
checkItems: string | null
|
||||
labels: string | null
|
||||
images: string | null
|
||||
@@ -8572,6 +8579,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: boolean
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: boolean
|
||||
labels?: boolean
|
||||
images?: boolean
|
||||
@@ -8614,6 +8622,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: boolean
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: boolean
|
||||
labels?: boolean
|
||||
images?: boolean
|
||||
@@ -8650,6 +8659,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: boolean
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: boolean
|
||||
labels?: boolean
|
||||
images?: boolean
|
||||
@@ -8710,6 +8720,7 @@ export namespace Prisma {
|
||||
isPinned: boolean
|
||||
isArchived: boolean
|
||||
type: string
|
||||
dismissedFromRecent: boolean
|
||||
checkItems: string | null
|
||||
labels: string | null
|
||||
images: string | null
|
||||
@@ -9141,6 +9152,7 @@ export namespace Prisma {
|
||||
readonly isPinned: FieldRef<"Note", 'Boolean'>
|
||||
readonly isArchived: FieldRef<"Note", 'Boolean'>
|
||||
readonly type: FieldRef<"Note", 'String'>
|
||||
readonly dismissedFromRecent: FieldRef<"Note", 'Boolean'>
|
||||
readonly checkItems: FieldRef<"Note", 'String'>
|
||||
readonly labels: FieldRef<"Note", 'String'>
|
||||
readonly images: FieldRef<"Note", 'String'>
|
||||
@@ -14662,6 +14674,7 @@ export namespace Prisma {
|
||||
isPinned: 'isPinned',
|
||||
isArchived: 'isArchived',
|
||||
type: 'type',
|
||||
dismissedFromRecent: 'dismissedFromRecent',
|
||||
checkItems: 'checkItems',
|
||||
labels: 'labels',
|
||||
images: 'images',
|
||||
@@ -15299,6 +15312,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFilter<"Note"> | boolean
|
||||
isArchived?: BoolFilter<"Note"> | boolean
|
||||
type?: StringFilter<"Note"> | string
|
||||
dismissedFromRecent?: BoolFilter<"Note"> | boolean
|
||||
checkItems?: StringNullableFilter<"Note"> | string | null
|
||||
labels?: StringNullableFilter<"Note"> | string | null
|
||||
images?: StringNullableFilter<"Note"> | string | null
|
||||
@@ -15340,6 +15354,7 @@ export namespace Prisma {
|
||||
isPinned?: SortOrder
|
||||
isArchived?: SortOrder
|
||||
type?: SortOrder
|
||||
dismissedFromRecent?: SortOrder
|
||||
checkItems?: SortOrderInput | SortOrder
|
||||
labels?: SortOrderInput | SortOrder
|
||||
images?: SortOrderInput | SortOrder
|
||||
@@ -15384,6 +15399,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFilter<"Note"> | boolean
|
||||
isArchived?: BoolFilter<"Note"> | boolean
|
||||
type?: StringFilter<"Note"> | string
|
||||
dismissedFromRecent?: BoolFilter<"Note"> | boolean
|
||||
checkItems?: StringNullableFilter<"Note"> | string | null
|
||||
labels?: StringNullableFilter<"Note"> | string | null
|
||||
images?: StringNullableFilter<"Note"> | string | null
|
||||
@@ -15425,6 +15441,7 @@ export namespace Prisma {
|
||||
isPinned?: SortOrder
|
||||
isArchived?: SortOrder
|
||||
type?: SortOrder
|
||||
dismissedFromRecent?: SortOrder
|
||||
checkItems?: SortOrderInput | SortOrder
|
||||
labels?: SortOrderInput | SortOrder
|
||||
images?: SortOrderInput | SortOrder
|
||||
@@ -15467,6 +15484,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolWithAggregatesFilter<"Note"> | boolean
|
||||
isArchived?: BoolWithAggregatesFilter<"Note"> | boolean
|
||||
type?: StringWithAggregatesFilter<"Note"> | string
|
||||
dismissedFromRecent?: BoolWithAggregatesFilter<"Note"> | boolean
|
||||
checkItems?: StringNullableWithAggregatesFilter<"Note"> | string | null
|
||||
labels?: StringNullableWithAggregatesFilter<"Note"> | string | null
|
||||
images?: StringNullableWithAggregatesFilter<"Note"> | string | null
|
||||
@@ -16401,6 +16419,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -16440,6 +16459,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -16479,6 +16499,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -16518,6 +16539,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -16557,6 +16579,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -16591,6 +16614,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -16623,6 +16647,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -17589,6 +17614,7 @@ export namespace Prisma {
|
||||
isPinned?: SortOrder
|
||||
isArchived?: SortOrder
|
||||
type?: SortOrder
|
||||
dismissedFromRecent?: SortOrder
|
||||
checkItems?: SortOrder
|
||||
labels?: SortOrder
|
||||
images?: SortOrder
|
||||
@@ -17629,6 +17655,7 @@ export namespace Prisma {
|
||||
isPinned?: SortOrder
|
||||
isArchived?: SortOrder
|
||||
type?: SortOrder
|
||||
dismissedFromRecent?: SortOrder
|
||||
checkItems?: SortOrder
|
||||
labels?: SortOrder
|
||||
images?: SortOrder
|
||||
@@ -17663,6 +17690,7 @@ export namespace Prisma {
|
||||
isPinned?: SortOrder
|
||||
isArchived?: SortOrder
|
||||
type?: SortOrder
|
||||
dismissedFromRecent?: SortOrder
|
||||
checkItems?: SortOrder
|
||||
labels?: SortOrder
|
||||
images?: SortOrder
|
||||
@@ -19373,6 +19401,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -19411,6 +19440,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -19763,6 +19793,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFilter<"Note"> | boolean
|
||||
isArchived?: BoolFilter<"Note"> | boolean
|
||||
type?: StringFilter<"Note"> | string
|
||||
dismissedFromRecent?: BoolFilter<"Note"> | boolean
|
||||
checkItems?: StringNullableFilter<"Note"> | string | null
|
||||
labels?: StringNullableFilter<"Note"> | string | null
|
||||
images?: StringNullableFilter<"Note"> | string | null
|
||||
@@ -20198,6 +20229,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -20236,6 +20268,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -20509,6 +20542,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -20547,6 +20581,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21217,6 +21252,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21255,6 +21291,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21427,6 +21464,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -21465,6 +21503,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -21556,6 +21595,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21594,6 +21634,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21707,6 +21748,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -21745,6 +21787,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -21836,6 +21879,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21874,6 +21918,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21917,6 +21962,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -21955,6 +22001,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -22068,6 +22115,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22106,6 +22154,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22155,6 +22204,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22193,6 +22243,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22390,6 +22441,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -22607,6 +22659,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22645,6 +22698,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22683,6 +22737,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22852,6 +22907,7 @@ export namespace Prisma {
|
||||
isPinned?: boolean
|
||||
isArchived?: boolean
|
||||
type?: string
|
||||
dismissedFromRecent?: boolean
|
||||
checkItems?: string | null
|
||||
labels?: string | null
|
||||
images?: string | null
|
||||
@@ -22914,6 +22970,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22952,6 +23009,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -22990,6 +23048,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -23023,6 +23082,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -23061,6 +23121,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
@@ -23099,6 +23160,7 @@ export namespace Prisma {
|
||||
isPinned?: BoolFieldUpdateOperationsInput | boolean
|
||||
isArchived?: BoolFieldUpdateOperationsInput | boolean
|
||||
type?: StringFieldUpdateOperationsInput | string
|
||||
dismissedFromRecent?: BoolFieldUpdateOperationsInput | boolean
|
||||
checkItems?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
labels?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
images?: NullableStringFieldUpdateOperationsInput | string | null
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-c7d0481a8b6fe66201cd99a6918bf4825dcac3497bdeb3b0ebcd8068fbb018d7",
|
||||
"name": "prisma-client-c6853a2d560fc459913c7f241f4427cd8f8957f5474f01e1a2cabb8c1f55d4d8",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
@@ -105,44 +105,45 @@ model Label {
|
||||
}
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
title String?
|
||||
content String
|
||||
color String @default("default")
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
type String @default("text")
|
||||
checkItems String?
|
||||
labels String?
|
||||
images String?
|
||||
links String?
|
||||
reminder DateTime?
|
||||
isReminderDone Boolean @default(false)
|
||||
reminderRecurrence String?
|
||||
reminderLocation String?
|
||||
isMarkdown Boolean @default(false)
|
||||
size String @default("small")
|
||||
embedding String?
|
||||
sharedWith String?
|
||||
userId String?
|
||||
order Int @default(0)
|
||||
notebookId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contentUpdatedAt DateTime @default(now())
|
||||
autoGenerated Boolean?
|
||||
aiProvider String?
|
||||
aiConfidence Int?
|
||||
language String?
|
||||
languageConfidence Float?
|
||||
lastAiAnalysis DateTime?
|
||||
aiFeedback AiFeedback[]
|
||||
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
|
||||
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
|
||||
notebook Notebook? @relation(fields: [notebookId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
shares NoteShare[]
|
||||
labelRelations Label[] @relation("LabelToNote")
|
||||
id String @id @default(cuid())
|
||||
title String?
|
||||
content String
|
||||
color String @default("default")
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
type String @default("text")
|
||||
dismissedFromRecent Boolean @default(false)
|
||||
checkItems String?
|
||||
labels String?
|
||||
images String?
|
||||
links String?
|
||||
reminder DateTime?
|
||||
isReminderDone Boolean @default(false)
|
||||
reminderRecurrence String?
|
||||
reminderLocation String?
|
||||
isMarkdown Boolean @default(false)
|
||||
size String @default("small")
|
||||
embedding String?
|
||||
sharedWith String?
|
||||
userId String?
|
||||
order Int @default(0)
|
||||
notebookId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contentUpdatedAt DateTime @default(now())
|
||||
autoGenerated Boolean?
|
||||
aiProvider String?
|
||||
aiConfidence Int?
|
||||
language String?
|
||||
languageConfidence Float?
|
||||
lastAiAnalysis DateTime?
|
||||
aiFeedback AiFeedback[]
|
||||
memoryEchoAsNote2 MemoryEchoInsight[] @relation("EchoNote2")
|
||||
memoryEchoAsNote1 MemoryEchoInsight[] @relation("EchoNote1")
|
||||
notebook Notebook? @relation(fields: [notebookId], references: [id])
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
shares NoteShare[]
|
||||
labelRelations Label[] @relation("LabelToNote")
|
||||
|
||||
@@index([isPinned])
|
||||
@@index([isArchived])
|
||||
@@ -227,7 +228,7 @@ model UserAISettings {
|
||||
preferredLanguage String @default("auto")
|
||||
fontSize String @default("medium")
|
||||
demoMode Boolean @default(false)
|
||||
showRecentNotes Boolean @default(false)
|
||||
showRecentNotes Boolean @default(true)
|
||||
emailNotifications Boolean @default(false)
|
||||
desktopNotifications Boolean @default(false)
|
||||
anonymousAnalytics Boolean @default(false)
|
||||
|
||||
@@ -193,6 +193,7 @@ exports.Prisma.NoteScalarFieldEnum = {
|
||||
isPinned: 'isPinned',
|
||||
isArchived: 'isArchived',
|
||||
type: 'type',
|
||||
dismissedFromRecent: 'dismissedFromRecent',
|
||||
checkItems: 'checkItems',
|
||||
labels: 'labels',
|
||||
images: 'images',
|
||||
|
||||
Binary file not shown.
@@ -112,6 +112,7 @@ model Note {
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
type String @default("text")
|
||||
dismissedFromRecent Boolean @default(false)
|
||||
checkItems String?
|
||||
labels String?
|
||||
images String?
|
||||
@@ -227,7 +228,7 @@ model UserAISettings {
|
||||
preferredLanguage String @default("auto")
|
||||
fontSize String @default("medium")
|
||||
demoMode Boolean @default(false)
|
||||
showRecentNotes Boolean @default(false)
|
||||
showRecentNotes Boolean @default(true)
|
||||
emailNotifications Boolean @default(false)
|
||||
desktopNotifications Boolean @default(false)
|
||||
anonymousAnalytics Boolean @default(false)
|
||||
|
||||
1
keep-notes/public/sw.js
Normal file
1
keep-notes/public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
keep-notes/public/workbox-f1770938.js
Normal file
1
keep-notes/public/workbox-f1770938.js
Normal file
File diff suppressed because one or more lines are too long
35
keep-notes/scripts/check-recent-notes-state.ts
Normal file
35
keep-notes/scripts/check-recent-notes-state.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
aiSettings: true,
|
||||
notes: {
|
||||
take: 5,
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Total Users:', users.length)
|
||||
|
||||
for (const user of users) {
|
||||
console.log(`User: ${user.email} (${user.id})`)
|
||||
console.log(` AI Settings:`, user.aiSettings)
|
||||
console.log(` Recent 5 Notes:`)
|
||||
for (const note of user.notes) {
|
||||
console.log(` ID: ${note.id}, Title: ${note.title}, Updated: ${note.updatedAt}, Dismissed: ${JSON.stringify(note)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
31
keep-notes/scripts/create-test-user.ts
Normal file
31
keep-notes/scripts/create-test-user.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { PrismaClient } from '../prisma/client-generated'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const email = 'test@example.com'
|
||||
const password = 'password123'
|
||||
const hashedPassword = await bcrypt.hash(password, 10)
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { password: hashedPassword },
|
||||
create: {
|
||||
email,
|
||||
name: 'Test User',
|
||||
password: hashedPassword,
|
||||
aiSettings: {
|
||||
create: {
|
||||
showRecentNotes: true // Ensure this is true!
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`User created/updated: ${user.email}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => console.error(e))
|
||||
.finally(async () => await prisma.$disconnect())
|
||||
60
keep-notes/scripts/debug-recent-notes.ts
Normal file
60
keep-notes/scripts/debug-recent-notes.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { PrismaClient } from '../prisma/client-generated'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// 1. Get a user
|
||||
const user = await prisma.user.findFirst()
|
||||
if (!user) {
|
||||
console.log('No user found in database.')
|
||||
return
|
||||
}
|
||||
console.log(`Checking for User ID: ${user.id} (${user.email})`)
|
||||
|
||||
// 2. Simulate logic from app/actions/notes.ts
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||||
sevenDaysAgo.setHours(0, 0, 0, 0)
|
||||
|
||||
console.log(`Filtering for notes updated after: ${sevenDaysAgo.toISOString()}`)
|
||||
|
||||
// 3. Query Raw
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, title: true, contentUpdatedAt: true, updatedAt: true, dismissedFromRecent: true, isArchived: true }
|
||||
})
|
||||
|
||||
console.log(`\nTotal Notes for User: ${allNotes.length}`)
|
||||
|
||||
// 4. Check "Recent" candidates
|
||||
const recentCandidates = allNotes.filter(n => {
|
||||
const noteDate = new Date(n.contentUpdatedAt)
|
||||
return noteDate >= sevenDaysAgo
|
||||
})
|
||||
|
||||
console.log(`Notes passing date filter: ${recentCandidates.length}`)
|
||||
recentCandidates.forEach(n => {
|
||||
console.log(` - [${n.title}] Updated: ${n.contentUpdatedAt.toISOString()} | Dismissed: ${n.dismissedFromRecent} | Archived: ${n.isArchived}`)
|
||||
})
|
||||
|
||||
// 5. Check what the actual query returns
|
||||
const actualQuery = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
contentUpdatedAt: { gte: sevenDaysAgo },
|
||||
isArchived: false,
|
||||
dismissedFromRecent: false
|
||||
},
|
||||
orderBy: { contentUpdatedAt: 'desc' },
|
||||
take: 3
|
||||
})
|
||||
|
||||
console.log(`\nActual Query Returns: ${actualQuery.length} notes`)
|
||||
actualQuery.forEach(n => {
|
||||
console.log(` -> [${n.title}]`)
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => console.error(e))
|
||||
.finally(async () => await prisma.$disconnect())
|
||||
39
keep-notes/scripts/fix-recent-notes-settings.ts
Normal file
39
keep-notes/scripts/fix-recent-notes-settings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { PrismaClient } from '../prisma/client-generated'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Updating user settings to show recent notes...')
|
||||
|
||||
const updateResult = await prisma.userAISettings.updateMany({
|
||||
data: {
|
||||
showRecentNotes: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Updated ${updateResult.count} user settings.`)
|
||||
|
||||
// Verify and Create missing
|
||||
const users = await prisma.user.findMany({
|
||||
include: { aiSettings: true }
|
||||
})
|
||||
|
||||
for (const u of users) {
|
||||
if (!u.aiSettings) {
|
||||
console.log(`User ${u.id} has no settings. Creating default...`)
|
||||
await prisma.userAISettings.create({
|
||||
data: {
|
||||
userId: u.id,
|
||||
showRecentNotes: true
|
||||
}
|
||||
})
|
||||
console.log(`Created settings for ${u.id}`)
|
||||
} else {
|
||||
console.log(`User ${u.id}: showRecentNotes = ${u.aiSettings.showRecentNotes}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(e => console.error(e))
|
||||
.finally(async () => await prisma.$disconnect())
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"26d59af0ae51ac745906-706525727fde24fa6600"
|
||||
"25d09793106dd133e58b-b170062a278a27e805be"
|
||||
]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [active] [ref=e1]:
|
||||
- main [ref=e4]:
|
||||
- generic [ref=e7]:
|
||||
- heading "Sign in to your account" [level=1] [ref=e8]
|
||||
- generic [ref=e9]:
|
||||
- generic [ref=e10]:
|
||||
- generic [ref=e11]: Email
|
||||
- textbox "Email" [ref=e13]:
|
||||
- /placeholder: Enter your email address
|
||||
- generic [ref=e14]:
|
||||
- generic [ref=e15]: Password
|
||||
- textbox "Password" [ref=e17]:
|
||||
- /placeholder: Enter your password
|
||||
- link "Forgot password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
- button "Sign In" [ref=e20]
|
||||
- region "Notifications alt+T"
|
||||
- button "Open Next.js Dev Tools" [ref=e27] [cursor=pointer]:
|
||||
- img [ref=e28]
|
||||
- alert [ref=e31]
|
||||
```
|
||||
62
keep-notes/tests/bug-repro-recent-notes.spec.ts
Normal file
62
keep-notes/tests/bug-repro-recent-notes.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Recent Notes Dismissal Bug', () => {
|
||||
test('should not replace dismissed note immediately', async ({ page }) => {
|
||||
// 1. Create 4 notes to ensure we have enough to fill the list (limit is 3) + 1 extra
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Create 4 notes
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const noteInput = page.locator('[data-testid="note-input-textarea"]')
|
||||
if (await noteInput.isVisible()) {
|
||||
await noteInput.fill(`Recent Note Test ${i} - ${Date.now()}`)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
// Wait for creation
|
||||
await page.waitForTimeout(500)
|
||||
} else {
|
||||
// If input not visible, click "New Note" or "Add Note" button first?
|
||||
// Assuming input is visible or toggleable.
|
||||
// Let's try to just open it if needed.
|
||||
const addBtn = page.locator('button:has-text("New Note")').first()
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click()
|
||||
}
|
||||
await page.locator('[data-testid="note-input-textarea"]').fill(`Recent Note Test ${i} - ${Date.now()}`)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh to update recent list
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const recentSection = page.locator('[data-testid="recent-notes-section"]')
|
||||
await expect(recentSection).toBeVisible()
|
||||
|
||||
// Should see 3 notes
|
||||
const noteCards = recentSection.locator('[data-testid^="note-card-"]')
|
||||
await expect(noteCards).toHaveCount(3)
|
||||
|
||||
// 2. Dismiss one note
|
||||
const firstNote = noteCards.first()
|
||||
// Hover to see the dismiss button
|
||||
await firstNote.hover()
|
||||
const dismissBtn = firstNote.locator('button[title*="Dismiss"], button[title*="Fermer"]') // Trying both EN and FR just in case
|
||||
|
||||
// We might need to force click if hover doesn't work perfectly in test
|
||||
await dismissBtn.click({ force: true })
|
||||
|
||||
// 3. Verify behavior
|
||||
// PRE-FIX: The list refreshes and shows 3 notes (the 4th one pops in)
|
||||
// POST-FIX: The list should show 2 notes
|
||||
|
||||
// We expect 2 notes if the fix works.
|
||||
// If the bug is present, this assertion might fail (it will see 3).
|
||||
// For reproduction, we might want to assert failure or just see what happens.
|
||||
// Let's assert the DESIRED behavior (2 notes).
|
||||
await expect(noteCards).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
@@ -30,5 +30,5 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "playwright-test.ts", "scripts", "tests"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user