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:
Sepehr Ramezani
2026-04-13 21:02:53 +02:00
parent 18ed116e0d
commit fa7e166f3e
3099 changed files with 397228 additions and 14584 deletions

View File

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

View File

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

View File

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

View File

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

View 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} />
}

View File

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

View 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,
}
}

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<{

View 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())

View File

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

View File

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

View File

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

View File

@@ -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">

View File

@@ -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
/>

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -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 &quot;{request.note.title || 'Untitled'}&quot;
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
</p>
</div>
<Badge

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

View File

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

View File

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

View File

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

View 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
}
}

View File

@@ -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;

View File

@@ -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++) {

View File

@@ -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": {

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -193,6 +193,7 @@ exports.Prisma.NoteScalarFieldEnum = {
isPinned: 'isPinned',
isArchived: 'isArchived',
type: 'type',
dismissedFromRecent: 'dismissedFromRecent',
checkItems: 'checkItems',
labels: 'labels',
images: 'images',

View File

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

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-c7d0481a8b6fe66201cd99a6918bf4825dcac3497bdeb3b0ebcd8068fbb018d7",
"name": "prisma-client-c6853a2d560fc459913c7f241f4427cd8f8957f5474f01e1a2cabb8c1f55d4d8",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

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

View File

@@ -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.

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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()
})

View 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())

View 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())

View 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())

View File

@@ -1,6 +1,6 @@
{
"status": "failed",
"failedTests": [
"26d59af0ae51ac745906-706525727fde24fa6600"
"25d09793106dd133e58b-b170062a278a27e805be"
]
}

View File

@@ -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]
```

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

View File

@@ -30,5 +30,5 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "playwright-test.ts", "scripts", "tests"]
}