WIP: Améliorations UX et corrections de bugs avant création des épiques

This commit is contained in:
2026-01-17 11:10:50 +01:00
parent 772dc77719
commit ef60dafd73
84 changed files with 11846 additions and 230 deletions

View File

@@ -3,7 +3,8 @@
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Note } from '@/lib/types'
import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { getAllNotes, getPinnedNotes, getRecentNotes, searchNotes } from '@/app/actions/notes'
import { getAISettings } from '@/app/actions/ai-settings'
import { NoteInput } from '@/components/note-input'
import { MasonryGrid } from '@/components/masonry-grid'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
@@ -11,6 +12,8 @@ import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { NoteEditor } from '@/components/note-editor'
import { BatchOrganizationDialog } from '@/components/batch-organization-dialog'
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
import { FavoritesSection } from '@/components/favorites-section'
import { RecentNotesSection } from '@/components/recent-notes-section'
import { Button } from '@/components/ui/button'
import { Wand2 } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
@@ -23,6 +26,9 @@ export default function HomePage() {
const searchParams = useSearchParams()
const router = useRouter()
const [notes, setNotes] = useState<Note[]>([])
const [pinnedNotes, setPinnedNotes] = useState<Note[]>([])
const [recentNotes, setRecentNotes] = useState<Note[]>([])
const [showRecentNotes, setShowRecentNotes] = useState(false)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
@@ -45,10 +51,67 @@ export default function HomePage() {
const notebookFilter = searchParams.get('notebook')
const isInbox = !notebookFilter
// Callback for NoteInput to trigger notebook suggestion
// Callback for NoteInput to trigger notebook suggestion and update UI
const handleNoteCreated = useCallback((note: Note) => {
console.log('[NotebookSuggestion] Note created:', { id: note.id, notebookId: note.notebookId, contentLength: note.content?.length })
// Update UI immediately by adding the note to the list if it matches current filters
setNotes((prevNotes) => {
// Check if note matches current filters
const notebookFilter = searchParams.get('notebook')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const search = searchParams.get('search')?.trim() || null
// Check notebook filter
if (notebookFilter && note.notebookId !== notebookFilter) {
return prevNotes // Note doesn't match notebook filter
}
if (!notebookFilter && note.notebookId) {
return prevNotes // Viewing inbox but note has notebook
}
// Check label filter
if (labelFilter.length > 0) {
const noteLabels = note.labels || []
if (!noteLabels.some((label: string) => labelFilter.includes(label))) {
return prevNotes // Note doesn't match label filter
}
}
// Check color filter
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
const noteLabels = note.labels || []
if (!noteLabels.some((label: string) => labelNamesWithColor.includes(label))) {
return prevNotes // Note doesn't match color filter
}
}
// Check search filter (simple check - if searching, let refresh handle it)
if (search) {
// If searching, refresh to get proper search results
router.refresh()
return prevNotes
}
// Note matches all filters - add it optimistically to the beginning of the list
// (newest notes first based on order: isPinned desc, order asc, updatedAt desc)
const isPinned = note.isPinned || false
const pinnedNotes = prevNotes.filter(n => n.isPinned)
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
if (isPinned) {
// Add to beginning of pinned notes
return [note, ...pinnedNotes, ...unpinnedNotes]
} else {
// Add to beginning of unpinned notes
return [...pinnedNotes, note, ...unpinnedNotes]
}
})
// Only suggest if note has no notebook and has 20+ words
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
@@ -66,7 +129,10 @@ export default function HomePage() {
} else {
console.log('[NotebookSuggestion] Note has notebook, skipping')
}
}, [])
// Refresh in background to ensure data consistency (non-blocking)
router.refresh()
}, [searchParams, labels, router])
const handleOpenNote = (noteId: string) => {
const note = notes.find(n => n.id === noteId)
@@ -78,6 +144,19 @@ export default function HomePage() {
// Enable reminder notifications
useReminderCheck(notes)
// Load user settings to check if recent notes should be shown
useEffect(() => {
const loadSettings = async () => {
try {
const settings = await getAISettings()
setShowRecentNotes(settings.showRecentNotes === true)
} catch (error) {
setShowRecentNotes(false)
}
}
loadSettings()
}, [refreshKey])
useEffect(() => {
const loadNotes = async () => {
setIsLoading(true)
@@ -117,13 +196,37 @@ export default function HomePage() {
)
}
// Load pinned notes separately (shown in favorites section)
const pinned = await getPinnedNotes()
// Filter pinned notes by current filters as well
if (notebookFilter) {
setPinnedNotes(pinned.filter((note: any) => note.notebookId === notebookFilter))
} else {
// If no notebook selected, only show pinned notes without notebook
setPinnedNotes(pinned.filter((note: any) => !note.notebookId))
}
// Load recent notes only if enabled in settings
if (showRecentNotes) {
const recent = await getRecentNotes(3)
// Filter recent notes by current filters
if (notebookFilter) {
setRecentNotes(recent.filter((note: any) => note.notebookId === notebookFilter))
} else {
setRecentNotes(recent.filter((note: any) => !note.notebookId))
}
} else {
setRecentNotes([])
}
setNotes(allNotes)
setIsLoading(false)
}
loadNotes()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey]) // Intentionally omit 'labels' and 'semantic' to prevent reload when adding tags or from router.push
}, [searchParams, refreshKey, showRecentNotes]) // Intentionally omit 'labels' and 'semantic' to prevent reload when adding tags or from router.push
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">
<NoteInput onNoteCreated={handleNoteCreated} />
@@ -145,10 +248,38 @@ export default function HomePage() {
{isLoading ? (
<div className="text-center py-8 text-gray-500">Loading...</div>
) : (
<MasonryGrid
notes={notes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
<>
{/* Favorites Section - Pinned Notes */}
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
{/* Recent Notes Section - Only shown if enabled in settings */}
{showRecentNotes && (
<RecentNotesSection
recentNotes={recentNotes.filter(note => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
)}
{/* Main Notes Grid - Unpinned Notes Only */}
{notes.filter(note => !note.isPinned).length > 0 && (
<div data-testid="notes-grid">
<MasonryGrid
notes={notes.filter(note => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
/>
</div>
)}
{/* Empty state when no notes */}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
<div className="text-center py-8 text-gray-500">
No notes yet. Create your first note!
</div>
)}
</>
)}
{/* Memory Echo - Proactive note connections */}
<MemoryEchoNotification onOpenNote={handleOpenNote} />

View File

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

View File

@@ -0,0 +1,187 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect, SettingInput } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export default function AISettingsPage() {
const { t } = useLanguage()
const [apiKey, setApiKey] = useState('')
// Mock settings state - in real implementation, load from server
const [settings, setSettings] = useState({
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily' as 'daily' | 'weekly' | 'custom',
aiProvider: 'auto' as 'auto' | 'openai' | 'ollama',
preferredLanguage: 'auto' as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: false
})
const handleToggle = async (feature: string, value: boolean) => {
setSettings(prev => ({ ...prev, [feature]: value }))
try {
await updateAISettings({ [feature]: value })
} catch (error) {
console.error('Error updating setting:', error)
toast.error('Failed to save setting')
setSettings(settings) // Revert on error
}
}
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
try {
await updateAISettings({ memoryEchoFrequency: value })
} catch (error) {
console.error('Error updating frequency:', error)
toast.error('Failed to save setting')
}
}
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
setSettings(prev => ({ ...prev, aiProvider: value }))
try {
await updateAISettings({ aiProvider: value })
} catch (error) {
console.error('Error updating provider:', error)
toast.error('Failed to save setting')
}
}
const handleApiKeyChange = async (value: string) => {
setApiKey(value)
// TODO: Implement API key persistence
console.log('API Key:', value)
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">AI Settings</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure AI-powered features and preferences
</p>
</div>
{/* AI Provider */}
<SettingsSection
title="AI Provider"
icon={<span className="text-2xl">🤖</span>}
description="Choose your preferred AI service provider"
>
<SettingSelect
label="Provider"
description="Select which AI service to use"
value={settings.aiProvider}
options={[
{
value: 'auto',
label: 'Auto-detect',
description: 'Ollama when available, OpenAI fallback'
},
{
value: 'ollama',
label: 'Ollama (Local)',
description: '100% private, runs locally on your machine'
},
{
value: 'openai',
label: 'OpenAI',
description: 'Most accurate, requires API key'
},
]}
onChange={handleProviderChange}
/>
{settings.aiProvider === 'openai' && (
<SettingInput
label="API Key"
description="Your OpenAI API key (stored securely)"
value={apiKey}
type="password"
placeholder="sk-..."
onChange={handleApiKeyChange}
/>
)}
</SettingsSection>
{/* Feature Toggles */}
<SettingsSection
title="AI Features"
icon={<span className="text-2xl"></span>}
description="Enable or disable AI-powered features"
>
<SettingToggle
label="Title Suggestions"
description="Suggest titles for untitled notes after 50+ words"
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<SettingToggle
label="Semantic Search"
description="Search by meaning, not just keywords"
checked={settings.semanticSearch}
onChange={(checked) => handleToggle('semanticSearch', checked)}
/>
<SettingToggle
label="Paragraph Reformulation"
description="AI-powered text improvement options"
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
<SettingToggle
label="Memory Echo"
description="Daily proactive note connections and insights"
checked={settings.memoryEcho}
onChange={(checked) => handleToggle('memoryEcho', checked)}
/>
{settings.memoryEcho && (
<SettingSelect
label="Memory Echo Frequency"
description="How often to analyze note connections"
value={settings.memoryEchoFrequency}
options={[
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'custom', label: 'Custom' },
]}
onChange={handleFrequencyChange}
/>
)}
</SettingsSection>
{/* Demo Mode */}
<SettingsSection
title="Demo Mode"
icon={<span className="text-2xl">🎭</span>}
description="Test AI features without using real AI calls"
>
<SettingToggle
label="Enable Demo Mode"
description="Use mock AI responses for testing and demonstrations"
checked={settings.demoMode}
onChange={(checked) => handleToggle('demoMode', checked)}
/>
</SettingsSection>
</main>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,200 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingInput } from '@/components/settings'
import { Button } from '@/components/ui/button'
import { Download, Upload, Trash2, Loader2, Check } from 'lucide-react'
import { toast } from 'sonner'
export default function DataSettingsPage() {
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [exportUrl, setExportUrl] = useState('')
const handleExport = async () => {
setIsExporting(true)
try {
const response = await fetch('/api/notes/export')
if (response.ok) {
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `keep-notes-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
toast.success('Notes exported successfully')
}
} catch (error) {
console.error('Export error:', error)
toast.error('Failed to export notes')
} finally {
setIsExporting(false)
}
}
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
setIsImporting(true)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/notes/import', {
method: 'POST',
body: formData
})
if (response.ok) {
const result = await response.json()
toast.success(`Imported ${result.count} notes`)
// Refresh the page to show imported notes
window.location.reload()
} else {
throw new Error('Import failed')
}
} catch (error) {
console.error('Import error:', error)
toast.error('Failed to import notes')
} finally {
setIsImporting(false)
// Reset input
event.target.value = ''
}
}
const handleDeleteAll = async () => {
if (!confirm('Are you sure you want to delete all notes? This action cannot be undone.')) {
return
}
setIsDeleting(true)
try {
const response = await fetch('/api/notes/delete-all', { method: 'POST' })
if (response.ok) {
toast.success('All notes deleted')
window.location.reload()
}
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to delete notes')
} finally {
setIsDeleting(false)
}
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Data Management</h1>
<p className="text-gray-600 dark:text-gray-400">
Export, import, or manage your data
</p>
</div>
<SettingsSection
title="Export Data"
icon={<span className="text-2xl">💾</span>}
description="Download your notes as a JSON file"
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">Export All Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Download all your notes in JSON format
</p>
</div>
<Button
onClick={handleExport}
disabled={isExporting}
>
{isExporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
{isExporting ? 'Exporting...' : 'Export'}
</Button>
</div>
</SettingsSection>
<SettingsSection
title="Import Data"
icon={<span className="text-2xl">📥</span>}
description="Import notes from a JSON file"
>
<div className="flex items-center justify-between py-4">
<div>
<p className="font-medium">Import Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Upload a JSON file to import notes
</p>
</div>
<div>
<input
type="file"
accept=".json"
onChange={handleImport}
disabled={isImporting}
className="hidden"
id="import-file"
/>
<Button
onClick={() => document.getElementById('import-file')?.click()}
disabled={isImporting}
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
{isImporting ? 'Importing...' : 'Import'}
</Button>
</div>
</div>
</SettingsSection>
<SettingsSection
title="Danger Zone"
icon={<span className="text-2xl"></span>}
description="Permanently delete your data"
>
<div className="flex items-center justify-between py-4 border-t border-red-200 dark:border-red-900">
<div>
<p className="font-medium text-red-600 dark:text-red-400">Delete All Notes</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
This action cannot be undone
</p>
</div>
<Button
variant="destructive"
onClick={handleDeleteAll}
disabled={isDeleting}
>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
{isDeleting ? 'Deleting...' : 'Delete All'}
</Button>
</div>
</SettingsSection>
</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingSelect, SettingsSearch } from '@/components/settings'
import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
export default function GeneralSettingsPage() {
const { t } = useLanguage()
const [language, setLanguage] = useState('auto')
const handleLanguageChange = async (value: string) => {
setLanguage(value)
await updateAISettings({ preferredLanguage: value as any })
}
const handleNotificationsChange = async (enabled: boolean) => {
// TODO: Implement notifications setting
console.log('Notifications:', enabled)
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">General Settings</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure basic application preferences
</p>
</div>
<SettingsSearch onSearch={(query) => console.log('Search:', query)} />
<SettingsSection
title="Language & Region"
icon={<span className="text-2xl">🌍</span>}
description="Choose your preferred language and regional settings"
>
<SettingSelect
label="Language"
description="Select the interface language"
value={language}
options={[
{ value: 'auto', label: 'Auto-detect' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fa', label: 'فارسی' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
]}
onChange={handleLanguageChange}
/>
</SettingsSection>
<SettingsSection
title="Notifications"
icon={<span className="text-2xl">🔔</span>}
description="Manage how and when you receive notifications"
>
<SettingToggle
label="Email Notifications"
description="Receive email updates about your notes"
checked={false}
onChange={handleNotificationsChange}
/>
<SettingToggle
label="Desktop Notifications"
description="Show notifications in your browser"
checked={false}
onChange={handleNotificationsChange}
/>
</SettingsSection>
<SettingsSection
title="Privacy"
icon={<span className="text-2xl">🔒</span>}
description="Control your privacy settings"
>
<SettingToggle
label="Anonymous Analytics"
description="Help improve the app with anonymous usage data"
checked={false}
onChange={handleNotificationsChange}
/>
</SettingsSection>
</main>
</div>
</div>
)
}

View File

@@ -1,184 +1,223 @@
'use client';
'use client'
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, CheckCircle, XCircle, RefreshCw, Trash2, Database, BrainCircuit } from 'lucide-react';
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes';
import { toast } from 'sonner';
import React, { useState } from 'react'
import { SettingsNav, SettingsSection } from '@/components/settings'
import { Button } from '@/components/ui/button'
import { Loader2, CheckCircle, XCircle, RefreshCw, Database, BrainCircuit } from 'lucide-react'
import { cleanupAllOrphans, syncAllEmbeddings } from '@/app/actions/notes'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import Link from 'next/link'
export default function SettingsPage() {
const [loading, setLoading] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [syncLoading, setSyncLoading] = useState(false);
const handleSync = async () => {
setSyncLoading(true);
try {
const result = await syncAllEmbeddings();
if (result.success) {
toast.success(`Indexing complete: ${result.count} notes processed`);
}
} catch (error) {
console.error(error);
toast.error("Error during indexing");
} finally {
setSyncLoading(false);
}
};
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [result, setResult] = useState<any>(null);
const [config, setConfig] = useState<any>(null);
const { t } = useLanguage()
const [loading, setLoading] = useState(false)
const [cleanupLoading, setCleanupLoading] = useState(false)
const [syncLoading, setSyncLoading] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [result, setResult] = useState<any>(null)
const [config, setConfig] = useState<any>(null)
const checkConnection = async () => {
setLoading(true);
setStatus('idle');
setResult(null);
setLoading(true)
setStatus('idle')
setResult(null)
try {
const res = await fetch('/api/ai/test');
const data = await res.json();
const res = await fetch('/api/ai/test')
const data = await res.json()
setConfig({
provider: data.provider,
status: res.ok ? 'connected' : 'disconnected'
});
})
if (res.ok) {
setStatus('success');
setResult(data);
setStatus('success')
setResult(data)
} else {
setStatus('error');
setResult(data);
setStatus('error')
setResult(data)
}
} catch (error: any) {
console.error(error);
setStatus('error');
setResult({ message: error.message, stack: error.stack });
console.error(error)
setStatus('error')
setResult({ message: error.message, stack: error.stack })
} finally {
setLoading(false);
setLoading(false)
}
};
}
const handleCleanup = async () => {
setCleanupLoading(true);
const handleSync = async () => {
setSyncLoading(true)
try {
const result = await cleanupAllOrphans();
const result = await syncAllEmbeddings()
if (result.success) {
toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`);
toast.success(`Indexing complete: ${result.count} notes processed`)
}
} catch (error) {
console.error(error);
toast.error("Error during cleanup");
console.error(error)
toast.error("Error during indexing")
} finally {
setCleanupLoading(false);
setSyncLoading(false)
}
};
}
useEffect(() => {
checkConnection();
}, []);
const handleCleanup = async () => {
setCleanupLoading(true)
try {
const result = await cleanupAllOrphans()
if (result.success) {
toast.success(result.message || `Cleanup complete: ${result.created} created, ${result.deleted} removed`)
}
} catch (error) {
console.error(error)
toast.error("Error during cleanup")
} finally {
setCleanupLoading(false)
}
}
return (
<div className="container mx-auto py-10 px-4 max-w-4xl space-y-8">
<h1 className="text-3xl font-bold mb-8">Settings</h1>
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<div>
<CardTitle className="flex items-center gap-2">
AI Diagnostics
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
</CardTitle>
<CardDescription>Check your AI provider connection status.</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Test Connection
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Current Configuration */}
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">Configured Provider</p>
<p className="text-lg font-mono">{config?.provider || '...'}</p>
</div>
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">API Status</p>
<Badge variant={status === 'success' ? 'default' : 'destructive'}>
{status === 'success' ? 'Operational' : 'Error'}
</Badge>
</div>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Settings</h1>
<p className="text-gray-600 dark:text-gray-400">
Configure your application settings
</p>
</div>
{/* Test Result */}
{result && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Test Details:</h3>
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${status === 'error' ? 'bg-red-50 text-red-900 border border-red-200' : 'bg-slate-950 text-slate-50'}`}>
<pre>{JSON.stringify(result, null, 2)}</pre>
{/* Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Link href="/settings/ai">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<BrainCircuit className="h-6 w-6 text-purple-500 mb-2" />
<h3 className="font-semibold">AI Settings</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI features and provider
</p>
</div>
</Link>
<Link href="/settings/profile">
<div className="p-4 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer">
<RefreshCw className="h-6 w-6 text-blue-500 mb-2" />
<h3 className="font-semibold">Profile Settings</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Manage your account and preferences
</p>
</div>
</Link>
</div>
{status === 'error' && (
<div className="text-sm text-red-600 mt-2">
<p className="font-bold">Troubleshooting Tips:</p>
<ul className="list-disc list-inside mt-1">
<li>Check that Ollama is running (<code>ollama list</code>)</li>
<li>Check the URL (http://localhost:11434)</li>
<li>Verify the model (e.g., granite4:latest) is downloaded</li>
<li>Check the Next.js server terminal for more logs</li>
</ul>
{/* AI Diagnostics */}
<SettingsSection
title="AI Diagnostics"
icon={<span className="text-2xl">🔍</span>}
description="Check your AI provider connection status"
>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">Configured Provider</p>
<p className="text-lg font-mono">{config?.provider || '...'}</p>
</div>
<div className="p-4 rounded-lg bg-secondary/50">
<p className="text-sm font-medium text-muted-foreground mb-1">API Status</p>
<div className="flex items-center gap-2">
{status === 'success' && <CheckCircle className="text-green-500 w-5 h-5" />}
{status === 'error' && <XCircle className="text-red-500 w-5 h-5" />}
<span className={`text-sm font-medium ${
status === 'success' ? 'text-green-600 dark:text-green-400' :
status === 'error' ? 'text-red-600 dark:text-red-400' :
'text-gray-600'
}`}>
{status === 'success' ? 'Operational' :
status === 'error' ? 'Error' :
'Checking...'}
</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5" />
Maintenance
</CardTitle>
<CardDescription>Tools to maintain your database health.</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium">Clean Orphan Tags</h3>
<p className="text-sm text-muted-foreground">Remove tags that are no longer used by any notes.</p>
</div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Trash2 className="w-4 h-4 mr-2" />}
Clean
</div>
{result && (
<div className="space-y-2 mt-4">
<h3 className="text-sm font-medium">Test Details:</h3>
<div className={`p-4 rounded-md font-mono text-xs overflow-x-auto ${
status === 'error'
? 'bg-red-50 text-red-900 border border-red-200 dark:bg-red-950 dark:text-red-100 dark:border-red-900'
: 'bg-slate-950 text-slate-50'
}`}>
<pre>{JSON.stringify(result, null, 2)}</pre>
</div>
{status === 'error' && (
<div className="text-sm text-red-600 dark:text-red-400 mt-2">
<p className="font-bold">Troubleshooting Tips:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Check that Ollama is running (<code className="bg-red-100 dark:bg-red-900 px-1 rounded">ollama list</code>)</li>
<li>Check URL (http://localhost:11434)</li>
<li>Verify model (e.g., granite4:latest) is downloaded</li>
<li>Check Next.js server terminal for more logs</li>
</ul>
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<Button variant="outline" size="sm" onClick={checkConnection} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Test Connection
</Button>
</div>
</SettingsSection>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Semantic Indexing
<Badge variant="outline" className="text-[10px]">AI</Badge>
</h3>
<p className="text-sm text-muted-foreground">Generate vectors for all notes to enable intent-based search.</p>
{/* Maintenance */}
<SettingsSection
title="Maintenance"
icon={<span className="text-2xl">🔧</span>}
description="Tools to maintain your database health"
>
<div className="space-y-4 py-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Clean Orphan Tags
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Remove tags that are no longer used by any notes
</p>
</div>
<Button variant="secondary" onClick={handleCleanup} disabled={cleanupLoading}>
{cleanupLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Database className="w-4 h-4 mr-2" />}
Clean
</Button>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<h3 className="font-medium flex items-center gap-2">
Semantic Indexing
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Generate vectors for all notes to enable intent-based search
</p>
</div>
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
Index All
</Button>
</div>
<Button variant="secondary" onClick={handleSync} disabled={syncLoading}>
{syncLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <BrainCircuit className="w-4 h-4 mr-2" />}
Index All
</Button>
</div>
</div>
</CardContent>
</Card>
</SettingsSection>
</main>
</div>
</div>
);
)
}

View File

@@ -0,0 +1,155 @@
'use client'
import { useState } from 'react'
import { SettingsNav, SettingsSection, SettingToggle, SettingInput, SettingSelect } from '@/components/settings'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
export default function ProfileSettingsPage() {
const { t } = useLanguage()
// Mock user data - in real implementation, load from server
const [user, setUser] = useState({
name: 'John Doe',
email: 'john@example.com'
})
const [language, setLanguage] = useState('auto')
const [showRecentNotes, setShowRecentNotes] = useState(false)
const handleNameChange = async (value: string) => {
setUser(prev => ({ ...prev, name: value }))
// TODO: Implement profile update
console.log('Name:', value)
}
const handleEmailChange = async (value: string) => {
setUser(prev => ({ ...prev, email: value }))
// TODO: Implement email update
console.log('Email:', value)
}
const handleLanguageChange = async (value: string) => {
setLanguage(value)
try {
await updateAISettings({ preferredLanguage: value as any })
} catch (error) {
console.error('Error updating language:', error)
toast.error('Failed to save language')
}
}
const handleRecentNotesChange = async (enabled: boolean) => {
setShowRecentNotes(enabled)
try {
await updateAISettings({ showRecentNotes: enabled })
} catch (error) {
console.error('Error updating recent notes setting:', error)
toast.error('Failed to save setting')
}
}
return (
<div className="container mx-auto py-10 px-4 max-w-6xl">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Sidebar Navigation */}
<aside className="lg:col-span-1">
<SettingsNav />
</aside>
{/* Main Content */}
<main className="lg:col-span-3 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Profile</h1>
<p className="text-gray-600 dark:text-gray-400">
Manage your account and personal information
</p>
</div>
{/* Profile Information */}
<SettingsSection
title="Profile Information"
icon={<span className="text-2xl">👤</span>}
description="Update your personal details"
>
<SettingInput
label="Name"
description="Your display name"
value={user.name}
onChange={handleNameChange}
placeholder="Enter your name"
/>
<SettingInput
label="Email"
description="Your email address"
value={user.email}
type="email"
onChange={handleEmailChange}
placeholder="Enter your email"
/>
</SettingsSection>
{/* Preferences */}
<SettingsSection
title="Preferences"
icon={<span className="text-2xl"></span>}
description="Customize your experience"
>
<SettingSelect
label="Language"
description="Choose your preferred language"
value={language}
options={[
{ value: 'auto', label: 'Auto-detect' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fa', label: 'فارسی' },
{ value: 'it', label: 'Italiano' },
{ value: 'pt', label: 'Português' },
{ value: 'ru', label: 'Русский' },
{ value: 'zh', label: '中文' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'pl', label: 'Polski' },
]}
onChange={handleLanguageChange}
/>
<SettingToggle
label="Show Recent Notes"
description="Display recently viewed notes in sidebar"
checked={showRecentNotes}
onChange={handleRecentNotesChange}
/>
</SettingsSection>
{/* AI Settings Link */}
<div className="p-6 border rounded-lg bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950 dark:to-pink-950">
<div className="flex items-center gap-4">
<div className="text-4xl"></div>
<div className="flex-1">
<h3 className="font-semibold text-lg mb-1">AI Settings</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Configure AI-powered features, provider selection, and preferences
</p>
</div>
<button
onClick={() => window.location.href = '/settings/ai'}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Configure
</button>
</div>
</div>
</main>
</div>
</div>
)
}

View File

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

View File

@@ -1,10 +1,12 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
@@ -12,7 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { updateProfile, changePassword, updateLanguage, updateFontSize } from '@/app/actions/profile'
import { updateProfile, changePassword, updateLanguage, updateFontSize, updateShowRecentNotes } from '@/app/actions/profile'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
@@ -36,10 +38,13 @@ const LANGUAGES = [
]
export function ProfileForm({ user, userAISettings }: { user: any; userAISettings?: any }) {
const router = useRouter()
const [selectedLanguage, setSelectedLanguage] = useState(userAISettings?.preferredLanguage || 'auto')
const [isUpdatingLanguage, setIsUpdatingLanguage] = useState(false)
const [fontSize, setFontSize] = useState(userAISettings?.fontSize || 'medium')
const [isUpdatingFontSize, setIsUpdatingFontSize] = useState(false)
const [showRecentNotes, setShowRecentNotes] = useState(userAISettings?.showRecentNotes ?? false)
const [isUpdatingRecentNotes, setIsUpdatingRecentNotes] = useState(false)
const { t } = useLanguage()
const FONT_SIZES = [
@@ -117,6 +122,28 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
}
}
const handleShowRecentNotesChange = async (enabled: boolean) => {
setIsUpdatingRecentNotes(true)
const previousValue = showRecentNotes
try {
const result = await updateShowRecentNotes(enabled)
if (result?.error) {
toast.error(result.error)
} else {
setShowRecentNotes(enabled)
toast.success(t('profile.recentNotesUpdateSuccess') || 'Paramètre mis à jour')
// Force full page reload to ensure settings are reloaded
window.location.href = '/settings/profile'
}
} catch (error: any) {
setShowRecentNotes(previousValue)
toast.error(error?.message || 'Erreur')
} finally {
setIsUpdatingRecentNotes(false)
}
}
return (
<div className="space-y-6">
<Card>
@@ -213,6 +240,23 @@ export function ProfileForm({ user, userAISettings }: { user: any; userAISetting
{t('profile.fontSizeDescription')}
</p>
</div>
<div className="flex items-center justify-between pt-4 border-t">
<div className="space-y-0.5">
<Label htmlFor="showRecentNotes" className="text-base font-medium">
{t('profile.showRecentNotes') || 'Afficher la section Récent'}
</Label>
<p className="text-sm text-muted-foreground">
{t('profile.showRecentNotesDescription') || 'Afficher les notes récentes (7 derniers jours) sur la page principale'}
</p>
</div>
<Switch
id="showRecentNotes"
checked={showRecentNotes}
onCheckedChange={handleShowRecentNotesChange}
disabled={isUpdatingRecentNotes}
/>
</div>
</CardContent>
</Card>

View File

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

View File

@@ -13,6 +13,7 @@ export type UserAISettingsData = {
aiProvider?: 'auto' | 'openai' | 'ollama'
preferredLanguage?: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
demoMode?: boolean
showRecentNotes?: boolean
}
/**
@@ -61,17 +62,34 @@ export async function getAISettings() {
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false
demoMode: false,
showRecentNotes: false
}
}
try {
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
// Use raw SQL query to get showRecentNotes until Prisma client is regenerated
const settingsRaw = await prisma.$queryRaw<Array<{
titleSuggestions: number
semanticSearch: number
paragraphRefactor: number
memoryEcho: number
memoryEchoFrequency: string
aiProvider: string
preferredLanguage: string
fontSize: string
demoMode: number
showRecentNotes: number
}>>`
SELECT titleSuggestions, semanticSearch, paragraphRefactor, memoryEcho,
memoryEchoFrequency, aiProvider, preferredLanguage, fontSize,
demoMode, showRecentNotes
FROM UserAISettings
WHERE userId = ${session.user.id}
`
// Return settings or defaults if not found
if (!settings) {
if (!settingsRaw || settingsRaw.length === 0) {
return {
titleSuggestions: true,
semanticSearch: true,
@@ -80,20 +98,29 @@ export async function getAISettings() {
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false
demoMode: false,
showRecentNotes: false
}
}
const settings = settingsRaw[0]
// Type-cast database values to proper union types
// Handle NULL values - SQLite can return NULL for showRecentNotes if column was added later
const showRecentNotesValue = settings.showRecentNotes !== null && settings.showRecentNotes !== undefined
? settings.showRecentNotes === 1
: false
return {
titleSuggestions: settings.titleSuggestions,
semanticSearch: settings.semanticSearch,
paragraphRefactor: settings.paragraphRefactor,
memoryEcho: settings.memoryEcho,
titleSuggestions: settings.titleSuggestions === 1,
semanticSearch: settings.semanticSearch === 1,
paragraphRefactor: settings.paragraphRefactor === 1,
memoryEcho: settings.memoryEcho === 1,
memoryEchoFrequency: (settings.memoryEchoFrequency || 'daily') as 'daily' | 'weekly' | 'custom',
aiProvider: (settings.aiProvider || 'auto') as 'auto' | 'openai' | 'ollama',
preferredLanguage: (settings.preferredLanguage || 'auto') as 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl',
demoMode: settings.demoMode || false
demoMode: settings.demoMode === 1,
showRecentNotes: showRecentNotesValue
}
} catch (error) {
console.error('Error getting AI settings:', error)
@@ -106,7 +133,8 @@ export async function getAISettings() {
memoryEchoFrequency: 'daily' as const,
aiProvider: 'auto' as const,
preferredLanguage: 'auto' as const,
demoMode: false
demoMode: false,
showRecentNotes: false
}
}
}

View File

@@ -364,6 +364,7 @@ export async function createNote(data: {
await syncLabels(session.user.id, data.labels)
}
// Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/')
return parseNote(note)
} catch (error) {
@@ -388,6 +389,7 @@ export async function updateNote(id: string, data: {
isMarkdown?: boolean
size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean | null
notebookId?: string | null
}) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
@@ -395,12 +397,13 @@ export async function updateNote(id: string, data: {
try {
const oldNote = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { labels: true }
select: { labels: true, notebookId: 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());
@@ -415,6 +418,7 @@ export async function updateNote(id: string, data: {
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
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
if ('notebookId' in data) updateData.notebookId = data.notebookId
updateData.updatedAt = new Date()
const note = await prisma.note.update({
@@ -428,9 +432,21 @@ export async function updateNote(id: string, data: {
await syncLabels(session.user.id, data.labels || [])
}
// Don't revalidatePath here - it would close the note editor dialog!
// The dialog will close via the onClose callback after save completes
// The UI will update via the normal React state management
// IMPORTANT: Call revalidatePath to ensure UI updates
// Revalidate main page, the note itself, and both old and new notebook paths
revalidatePath('/')
revalidatePath(`/note/${id}`)
// If notebook changed, revalidate both notebook paths
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
if (oldNotebookId) {
revalidatePath(`/notebook/${oldNotebookId}`)
}
if (data.notebookId) {
revalidatePath(`/notebook/${data.notebookId}`)
}
}
return parseNote(note)
} catch (error) {
console.error('Error updating note:', error)
@@ -713,6 +729,62 @@ export async function getAllNotes(includeArchived = false) {
}
}
// Get pinned notes only
export async function getPinnedNotes() {
const session = await auth();
if (!session?.user?.id) return [];
const userId = session.user.id;
try {
const notes = await prisma.note.findMany({
where: {
userId: userId,
isPinned: true,
isArchived: false
},
orderBy: [
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching pinned notes:', error)
return []
}
}
// Get recent notes (notes modified in the last 7 days)
export async function getRecentNotes(limit: number = 3) {
const session = await auth();
if (!session?.user?.id) return [];
const userId = session.user.id;
try {
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
sevenDaysAgo.setHours(0, 0, 0, 0) // Set to start of day
const notes = await prisma.note.findMany({
where: {
userId: userId,
updatedAt: { gte: sevenDaysAgo },
isArchived: false
},
orderBy: { updatedAt: 'desc' },
take: limit
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching recent notes:', error)
return []
}
}
export async function getNoteById(noteId: string) {
const session = await auth();
if (!session?.user?.id) return null;

View File

@@ -93,6 +93,8 @@ export async function updateTheme(theme: string) {
where: { id: session.user.id },
data: { theme },
})
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true }
} catch (error) {
return { error: 'Failed to update theme' }
@@ -118,6 +120,7 @@ export async function updateLanguage(language: string) {
// Note: The language will be applied on next page load
// The client component should handle updating localStorage and reloading
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, language }
} catch (error) {
@@ -156,11 +159,13 @@ export async function updateFontSize(fontSize: string) {
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto'
preferredLanguage: 'auto',
showRecentNotes: false
}
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, fontSize }
} catch (error) {
@@ -168,3 +173,60 @@ export async function updateFontSize(fontSize: string) {
return { error: 'Failed to update font size' }
}
}
export async function updateShowRecentNotes(showRecentNotes: boolean) {
const session = await auth()
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
// Use EXACT same pattern as updateFontSize which works
const existing = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
if (existing) {
// Try Prisma client first, fallback to raw SQL if field doesn't exist in client
try {
await prisma.userAISettings.update({
where: { userId: session.user.id },
data: { showRecentNotes: showRecentNotes } as any
})
} catch (prismaError: any) {
// If Prisma client doesn't know about showRecentNotes, use raw SQL
if (prismaError?.message?.includes('Unknown argument') || prismaError?.code === 'P2009') {
const value = showRecentNotes ? 1 : 0
await prisma.$executeRaw`
UPDATE UserAISettings
SET showRecentNotes = ${value}
WHERE userId = ${session.user.id}
`
} else {
throw prismaError
}
}
} else {
// Create new - same as updateFontSize
await prisma.userAISettings.create({
data: {
userId: session.user.id,
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto',
preferredLanguage: 'auto',
fontSize: 'medium',
showRecentNotes: showRecentNotes
} as any
})
}
revalidatePath('/')
revalidatePath('/settings/profile')
return { success: true, showRecentNotes }
} catch (error) {
console.error('[updateShowRecentNotes] Failed:', error)
return { error: 'Failed to update show recent notes setting' }
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
'use client'
import { useState } from 'react'
import { Note } from '@/lib/types'
import { NoteCard } from './note-card'
import { ChevronDown, ChevronUp } from 'lucide-react'
interface FavoritesSectionProps {
pinnedNotes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
}
export function FavoritesSection({ pinnedNotes, onEdit }: FavoritesSectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false)
// Don't show section if no pinned notes
if (pinnedNotes.length === 0) {
return null
}
return (
<section data-testid="favorites-section" className="mb-8">
{/* Collapsible Header */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="w-full flex items-center justify-between gap-2 mb-4 px-2 py-2 hover:bg-accent rounded-lg transition-colors min-h-[44px]"
aria-expanded={!isCollapsed}
>
<div className="flex items-center gap-2">
<span className="text-2xl">📌</span>
<h2 className="text-lg font-semibold text-foreground">
Pinned Notes
<span className="text-sm font-medium text-muted-foreground ml-2">
({pinnedNotes.length})
</span>
</h2>
</div>
{isCollapsed ? (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
)}
</button>
{/* Collapsible Content */}
{!isCollapsed && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pinnedNotes.map((note) => (
<NoteCard
key={note.id}
note={note}
onEdit={onEdit}
/>
))}
</div>
)}
</section>
)
}

View File

@@ -77,6 +77,41 @@ export function Header({
setNotebookId(currentNotebook || null)
}, [currentNotebook, setNotebookId])
// Prevent body scroll when mobile menu is open
useEffect(() => {
if (isSidebarOpen) {
document.body.style.overflow = 'hidden'
document.body.style.position = 'fixed'
document.body.style.width = '100%'
} else {
document.body.style.overflow = ''
document.body.style.position = ''
document.body.style.width = ''
}
return () => {
document.body.style.overflow = ''
document.body.style.position = ''
document.body.style.width = ''
}
}, [isSidebarOpen])
// Close mobile menu on Esc key press
useEffect(() => {
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isSidebarOpen) {
setIsSidebarOpen(false)
}
}
if (isSidebarOpen) {
document.addEventListener('keydown', handleEscapeKey)
}
return () => {
document.removeEventListener('keydown', handleEscapeKey)
}
}, [isSidebarOpen])
// Simple debounced search with URL update (150ms for more responsiveness)
const debouncedSearchQuery = useDebounce(searchQuery, 150)
@@ -224,6 +259,8 @@ export function Header({
? "bg-[#EFB162] text-amber-900"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
style={{ minHeight: '44px' }}
aria-pressed={active}
>
{content}
</button>
@@ -240,6 +277,8 @@ export function Header({
? "bg-[#EFB162] text-amber-900"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
style={{ minHeight: '44px' }}
aria-current={active ? 'page' : undefined}
>
{content}
</Link>
@@ -250,20 +289,36 @@ export function Header({
return (
<>
<header className="h-20 bg-background-light/90 dark:bg-background-dark/90 backdrop-blur-sm border-b border-transparent flex items-center justify-between px-6 lg:px-12 flex-shrink-0 z-30 sticky top-0">
<header className="h-20 bg-background/90 backdrop-blur-sm border-b border-transparent flex items-center justify-between px-6 lg:px-12 flex-shrink-0 z-30 sticky top-0">
{/* Mobile Menu Button */}
<Sheet open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden mr-4 text-slate-500 dark:text-slate-400">
<Button
variant="ghost"
size="icon"
className="lg:hidden mr-4 text-muted-foreground"
aria-label="Open menu"
aria-expanded={isSidebarOpen}
>
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[280px] sm:w-[320px] p-0 pt-4">
<SheetHeader className="px-4 mb-4">
<SheetHeader className="px-4 mb-4 flex items-center justify-between">
<SheetTitle className="flex items-center gap-2 text-xl font-normal">
<StickyNote className="h-6 w-6 text-amber-500" />
<StickyNote className="h-6 w-6 text-primary" />
{t('nav.workspace')}
</SheetTitle>
<Button
variant="ghost"
size="icon"
onClick={() => setIsSidebarOpen(false)}
className="text-muted-foreground hover:text-foreground"
aria-label="Close menu"
style={{ width: '44px', height: '44px' }}
>
<X className="h-5 w-5" />
</Button>
</SheetHeader>
<div className="flex flex-col gap-1 py-2">
<NavItem
@@ -280,7 +335,7 @@ export function Header({
/>
<div className="my-2 px-4 flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">{t('labels.title')}</span>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t('labels.title')}</span>
</div>
{labels.map(label => (
@@ -312,10 +367,10 @@ export function Header({
</Sheet>
{/* Search Bar */}
<div className="flex-1 max-w-2xl flex items-center bg-white dark:bg-slate-800/80 rounded-2xl px-4 py-3 shadow-sm border border-transparent focus-within:border-indigo-500/50 focus-within:ring-2 ring-indigo-500/10 transition-all">
<Search className="text-slate-400 dark:text-slate-500 text-xl" />
<div className="flex-1 max-w-2xl flex items-center bg-card rounded-lg px-4 py-3 shadow-sm border border-transparent focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/10 transition-all">
<Search className="text-muted-foreground text-xl" />
<input
className="bg-transparent border-none outline-none focus:ring-0 w-full text-sm text-slate-700 dark:text-slate-200 ml-3 placeholder-slate-400"
className="bg-transparent border-none outline-none focus:ring-0 w-full text-sm text-foreground ml-3 placeholder-muted-foreground"
placeholder={t('search.placeholder') || "Search notes, tags, or notebooks..."}
type="text"
value={searchQuery}
@@ -327,11 +382,11 @@ export function Header({
onClick={handleSemanticSearch}
disabled={!searchQuery.trim() || isSemanticSearching}
className={cn(
"flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors",
"hover:bg-indigo-100 dark:hover:bg-indigo-900/30",
"flex items-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-colors min-h-[36px]",
"hover:bg-accent",
searchParams.get('semantic') === 'true'
? "bg-indigo-200 dark:bg-indigo-900/50 text-indigo-900 dark:text-indigo-100"
: "text-gray-500 dark:text-gray-400 hover:text-indigo-700 dark:hover:text-indigo-300",
? "bg-primary/20 text-primary"
: "text-muted-foreground hover:text-primary",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
title={t('search.semanticTooltip')}
@@ -342,7 +397,7 @@ export function Header({
{searchQuery && (
<button
onClick={() => handleSearch('')}
className="ml-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200"
className="ml-2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
@@ -358,14 +413,14 @@ export function Header({
/>
{/* Grid View Button */}
<button className="p-2.5 text-slate-500 hover:bg-white hover:shadow-sm dark:text-slate-400 dark:hover:bg-slate-700 rounded-xl transition-all duration-200">
<button className="p-2.5 text-muted-foreground hover:bg-accent rounded-lg transition-colors duration-200 min-h-[44px] min-w-[44px]">
<Grid3x3 className="text-xl" />
</button>
{/* Theme Toggle */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-2.5 text-slate-500 hover:bg-white hover:shadow-sm dark:text-slate-400 dark:hover:bg-slate-700 rounded-xl transition-all duration-200">
<button className="p-2.5 text-muted-foreground hover:bg-accent rounded-lg transition-colors duration-200 min-h-[44px] min-w-[44px]">
{theme === 'light' ? <Sun className="text-xl" /> : <Moon className="text-xl" />}
</button>
</DropdownMenuTrigger>
@@ -384,12 +439,12 @@ export function Header({
{/* Active Filters Bar */}
{hasActiveFilters && (
<div className="px-6 lg:px-12 pb-3 flex items-center gap-2 overflow-x-auto border-t border-gray-100 dark:border-zinc-800 pt-2 bg-white/50 dark:bg-zinc-900/50 backdrop-blur-sm animate-in slide-in-from-top-2">
<div className="px-6 lg:px-12 pb-3 flex items-center gap-2 overflow-x-auto border-t border-border pt-2 bg-background/50 backdrop-blur-sm animate-in slide-in-from-top-2">
{currentColor && (
<Badge variant="secondary" className="flex items-center gap-1 h-7 whitespace-nowrap pl-2 pr-1">
<div className={cn("w-3 h-3 rounded-full border border-black/10", `bg-${currentColor}-500`)} />
{t('notes.color')}: {currentColor}
<button onClick={removeColorFilter} className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5">
<button onClick={removeColorFilter} className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5 min-h-[24px] min-w-[24px]">
<X className="h-3 w-3" />
</button>
</Badge>
@@ -409,7 +464,7 @@ export function Header({
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="h-7 text-xs text-indigo-600 hover:text-indigo-700 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-900/20 whitespace-nowrap ml-auto"
className="h-7 text-xs text-primary hover:text-primary hover:bg-accent whitespace-nowrap ml-auto"
>
{t('labels.clearAll')}
</Button>

View File

@@ -157,12 +157,15 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
if (!isMounted) return;
// Detect if we are on a touch device (mobile behavior)
const isMobile = window.matchMedia('(pointer: coarse)').matches;
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const isMobileWidth = window.innerWidth < 768;
const isMobile = isTouchDevice || isMobileWidth;
const layoutOptions = {
dragEnabled: true,
// Always use specific drag handle to avoid conflicts
dragHandle: '.muuri-drag-handle',
// Use drag handle for mobile devices to allow smooth scrolling
// On desktop, whole card is draggable (no handle needed)
dragHandle: isMobile ? '.muuri-drag-handle' : undefined,
dragContainer: document.body,
dragStartPredicate: {
distance: 10,

View File

@@ -32,6 +32,7 @@ import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import { toast } from 'sonner'
// Mapping of supported languages to date-fns locales
const localeMap: Record<string, Locale> = {
@@ -135,7 +136,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
router.refresh()
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
}
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
@@ -198,6 +199,13 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
router.refresh()
// Show toast notification
if (!note.isPinned) {
toast.success('Note épinglée')
} else {
toast.info('Note désépinglée')
}
})
}
@@ -263,8 +271,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
<Card
data-testid="note-card"
className={cn(
'note-card-main group relative p-4 transition-all duration-200 border cursor-move',
'hover:shadow-md',
'note-card group relative rounded-lg p-4 transition-all duration-200 border shadow-sm hover:shadow-md',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,
@@ -273,12 +280,21 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
onClick={(e) => {
// Only trigger edit if not clicking on buttons
const target = e.target as HTMLElement
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.drag-handle')) {
if (!target.closest('button') && !target.closest('[role="checkbox"]') && !target.closest('.muuri-drag-handle') && !target.closest('.drag-handle')) {
// For shared notes, pass readOnly flag
onEdit?.(note, !!isSharedNote) // Pass second parameter as readOnly flag (convert to boolean)
}
}}
>
{/* Drag Handle - Only visible on mobile/touch devices */}
<div
className="muuri-drag-handle absolute top-2 left-2 z-20 cursor-grab active:cursor-grabbing p-2 md:hidden"
aria-label={t('notes.dragToReorder') || 'Drag to reorder'}
title={t('notes.dragToReorder') || 'Drag to reorder'}
>
<GripVertical className="h-5 w-5 text-muted-foreground" />
</div>
{/* Move to Notebook Dropdown Menu */}
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
@@ -321,7 +337,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
variant="ghost"
size="sm"
className={cn(
"absolute top-2 right-12 z-20 h-8 w-8 p-0 rounded-full transition-opacity",
"absolute top-2 right-12 z-20 min-h-[44px] min-w-[44px] h-8 w-8 p-0 rounded-md transition-opacity",
optimisticNote.isPinned ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => {
@@ -330,14 +346,14 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
}}
>
<Pin
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-blue-600" : "text-gray-400")}
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
/>
</Button>
{/* Reminder Icon - Move slightly if pin button is there */}
{note.reminder && new Date(note.reminder) > new Date() && (
<Bell
className="absolute top-3 right-10 h-4 w-4 text-blue-600 dark:text-blue-400"
className="absolute top-3 right-10 h-4 w-4 text-primary"
/>
)}
@@ -373,7 +389,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
{/* Title */}
{optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-gray-900 dark:text-gray-100">
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
{optimisticNote.title}
</h3>
)}
@@ -446,7 +462,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
{/* Content */}
{optimisticNote.type === 'text' ? (
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-10">
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent content={optimisticNote.content} />
</div>
) : (
@@ -468,7 +484,7 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver, onDragStart, on
{/* Footer with Date only */}
<div className="mt-3 flex items-center justify-end">
{/* Creation Date */}
<div className="text-xs text-gray-500 dark:text-gray-400">
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
</div>
</div>

View File

@@ -87,7 +87,7 @@ export function NotebookSuggestionToast({
try {
// Move note to suggested notebook
await moveNoteToNotebookOptimistic(noteId, suggestion.id)
router.refresh()
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
handleDismiss()
} catch (error) {
console.error('Failed to move note to notebook:', error)

View File

@@ -59,11 +59,11 @@ export function NotebooksList() {
if (noteId) {
await moveNoteToNotebookOptimistic(noteId, notebookId)
router.refresh() // Refresh the page to show the moved note
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
}
dragOver(null)
}, [moveNoteToNotebookOptimistic, dragOver, router])
}, [moveNoteToNotebookOptimistic, dragOver])
// Handle drag over a notebook
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {

View File

@@ -0,0 +1,153 @@
'use client'
import { Note } from '@/lib/types'
import { Clock, FileText, Tag } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface RecentNotesSectionProps {
recentNotes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
}
export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionProps) {
const { language } = useLanguage()
// Show only the 3 most recent notes
const topThree = recentNotes.slice(0, 3)
if (topThree.length === 0) return null
return (
<section data-testid="recent-notes-section" className="mb-6">
{/* Minimalist header - matching your app style */}
<div className="flex items-center gap-2 mb-3 px-1">
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{language === 'fr' ? 'Récent' : 'Recent'}
</span>
<span className="text-xs text-muted-foreground">
· {topThree.length}
</span>
</div>
{/* Compact 3-card row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{topThree.map((note, index) => (
<CompactCard
key={note.id}
note={note}
index={index}
onEdit={onEdit}
/>
))}
</div>
</section>
)
}
// Compact card - matching your app's clean design
function CompactCard({
note,
index,
onEdit
}: {
note: Note
index: number
onEdit?: (note: Note, readOnly?: boolean) => void
}) {
const { language } = useLanguage()
// NOTE: Using updatedAt here, but note-card.tsx uses createdAt
// If times are incorrect, consider using createdAt instead or ensure dates are properly parsed
const timeAgo = getCompactTime(note.updatedAt, language)
const isFirstNote = index === 0
return (
<button
onClick={() => onEdit?.(note)}
className={cn(
"group relative text-left p-4 bg-card border rounded-xl shadow-sm hover:shadow-md transition-all duration-200 min-h-[44px]",
isFirstNote && "ring-2 ring-primary/20"
)}
>
{/* Subtle left accent - colored based on recency */}
<div className={cn(
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
isFirstNote
? "bg-gradient-to-b from-blue-500 to-indigo-500"
: index === 1
? "bg-blue-400 dark:bg-blue-500"
: "bg-gray-300 dark:bg-gray-600"
)} />
{/* Content with left padding for accent line */}
<div className="pl-2">
{/* Title */}
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
{note.title || (language === 'fr' ? 'Sans titre' : 'Untitled')}
</h3>
{/* Preview - 2 lines max */}
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
{note.content?.substring(0, 80) || ''}
{note.content && note.content.length > 80 && '...'}
</p>
{/* Footer with time and indicators */}
<div className="flex items-center justify-between pt-2 border-t border-border">
{/* Time - left */}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
<span className="font-medium">{timeAgo}</span>
</span>
{/* Indicators - right */}
<div className="flex items-center gap-1.5">
{/* Notebook indicator */}
{note.notebookId && (
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 dark:bg-blue-400" title="In notebook" />
)}
{/* Labels indicator */}
{note.labels && note.labels.length > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={`${note.labels.length} ${language === 'fr' ? 'étiquettes' : 'labels'}`} />
)}
</div>
</div>
</div>
{/* Hover indicator - top right */}
<div className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</button>
)
}
// Compact time display - matching your app's style
// NOTE: Ensure dates are properly parsed from database (may come as strings)
function getCompactTime(date: Date | string, language: string): string {
const now = new Date()
const then = date instanceof Date ? date : new Date(date)
// Validate date
if (isNaN(then.getTime())) {
console.warn('Invalid date provided to getCompactTime:', date)
return language === 'fr' ? 'date invalide' : 'invalid date'
}
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (language === 'fr') {
if (seconds < 60) return 'à l\'instant'
if (minutes < 60) return `il y a ${minutes}m`
if (hours < 24) return `il y a ${hours}h`
const days = Math.floor(hours / 24)
return `il y a ${days}j`
} else {
if (seconds < 60) return 'just now'
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
}

View File

@@ -0,0 +1,88 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Loader2, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface SettingInputProps {
label: string
description?: string
value: string
type?: 'text' | 'password' | 'email' | 'url'
onChange: (value: string) => Promise<void>
placeholder?: string
disabled?: boolean
}
export function SettingInput({
label,
description,
value,
type = 'text',
onChange,
placeholder,
disabled
}: SettingInputProps) {
const [isLoading, setIsLoading] = useState(false)
const [isSaved, setIsSaved] = useState(false)
const handleChange = async (newValue: string) => {
setIsLoading(true)
setIsSaved(false)
try {
await onChange(newValue)
setIsSaved(true)
toast.success('Setting saved')
// Clear saved indicator after 2 seconds
setTimeout(() => setIsSaved(false), 2000)
} catch (err) {
console.error('Error updating setting:', err)
toast.error('Failed to save setting', {
description: 'Please try again'
})
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<input
type={type}
value={value}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100',
'placeholder:text-gray-400 dark:placeholder:text-gray-600'
)}
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
{isSaved && !isLoading && (
<Check className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface SelectOption {
value: string
label: string
description?: string
}
interface SettingSelectProps {
label: string
description?: string
value: string
options: SelectOption[]
onChange: (value: string) => Promise<void>
disabled?: boolean
}
export function SettingSelect({
label,
description,
value,
options,
onChange,
disabled
}: SettingSelectProps) {
const [isLoading, setIsLoading] = useState(false)
const handleChange = async (newValue: string) => {
setIsLoading(true)
try {
await onChange(newValue)
toast.success('Setting saved', {
description: `${label} has been updated`
})
} catch (err) {
console.error('Error updating setting:', err)
toast.error('Failed to save setting', {
description: 'Please try again'
})
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<select
value={value}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'appearance-none bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100'
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
'use client'
import { useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Loader2, Check, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
interface SettingToggleProps {
label: string
description?: string
checked: boolean
onChange: (checked: boolean) => Promise<void>
disabled?: boolean
}
export function SettingToggle({
label,
description,
checked,
onChange,
disabled
}: SettingToggleProps) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState(false)
const handleChange = async (newChecked: boolean) => {
setIsLoading(true)
setError(false)
try {
await onChange(newChecked)
toast.success('Setting saved', {
description: `${label} has been ${newChecked ? 'enabled' : 'disabled'}`
})
} catch (err) {
console.error('Error updating setting:', err)
setError(true)
toast.error('Failed to save setting', {
description: 'Please try again'
})
} finally {
setIsLoading(false)
}
}
return (
<div className={cn(
'flex items-center justify-between py-4',
'border-b last:border-0 dark:border-gray-800'
)}>
<div className="flex-1 pr-4">
<Label className="font-medium text-gray-900 dark:text-gray-100 cursor-pointer">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-gray-500" />}
{!isLoading && !error && checked && <Check className="h-4 w-4 text-green-500" />}
{!isLoading && !error && !checked && <X className="h-4 w-4 text-gray-400" />}
<Switch
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || isLoading}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SettingsSection {
id: string
label: string
icon: React.ReactNode
href: string
}
interface SettingsNavProps {
className?: string
}
export function SettingsNav({ className }: SettingsNavProps) {
const pathname = usePathname()
const sections: SettingsSection[] = [
{
id: 'general',
label: 'General',
icon: <Settings className="h-5 w-5" />,
href: '/settings/general'
},
{
id: 'ai',
label: 'AI',
icon: <Sparkles className="h-5 w-5" />,
href: '/settings/ai'
},
{
id: 'appearance',
label: 'Appearance',
icon: <Palette className="h-5 w-5" />,
href: '/settings/appearance'
},
{
id: 'profile',
label: 'Profile',
icon: <User className="h-5 w-5" />,
href: '/settings/profile'
},
{
id: 'data',
label: 'Data',
icon: <Database className="h-5 w-5" />,
href: '/settings/data'
},
{
id: 'about',
label: 'About',
icon: <Info className="h-5 w-5" />,
href: '/settings/about'
}
]
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/')
return (
<nav className={cn('space-y-1', className)}>
{sections.map((section) => (
<Link
key={section.id}
href={section.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors',
'hover:bg-gray-100 dark:hover:bg-gray-800',
isActive(section.href)
? 'bg-gray-100 dark:bg-gray-800 text-primary'
: 'text-gray-700 dark:text-gray-300'
)}
>
{isActive(section.href) && (
<Check className="h-4 w-4 text-primary" />
)}
{!isActive(section.href) && (
<div className="w-4" />
)}
{section.icon}
<span className="font-medium">{section.label}</span>
</Link>
))}
</nav>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface SettingsSearchProps {
onSearch: (query: string) => void
placeholder?: string
className?: string
}
export function SettingsSearch({
onSearch,
placeholder = 'Search settings...',
className
}: SettingsSearchProps) {
const [query, setQuery] = useState('')
const handleChange = (value: string) => {
setQuery(value)
onSearch(value)
}
return (
<div className={cn('relative', className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
className="pl-10"
/>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface SettingsSectionProps {
title: string
description?: string
icon?: React.ReactNode
children: React.ReactNode
className?: string
}
export function SettingsSection({
title,
description,
icon,
children,
className
}: SettingsSectionProps) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{icon}
{title}
</CardTitle>
{description && (
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{description}
</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{children}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,6 @@
export { SettingsNav } from './SettingsNav'
export { SettingsSection } from './SettingsSection'
export { SettingToggle } from './SettingToggle'
export { SettingSelect } from './SettingSelect'
export { SettingInput } from './SettingInput'
export { SettingsSearch } from './SettingsSearch'

View File

@@ -2,6 +2,7 @@
import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'
import type { Notebook, Label, Note } from '@/lib/types'
import { useNoteRefresh } from './NoteRefreshContext'
// ===== INPUT TYPES =====
export interface CreateNotebookInput {
@@ -77,6 +78,7 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { triggerRefresh } = useNoteRefresh() // Get triggerRefresh from context
// ===== DERIVED STATE =====
const currentLabels = useMemo(() => {
@@ -219,7 +221,10 @@ export function NotebooksProvider({ children, initialNotebooks = [] }: Notebooks
// Reload notebooks to update note counts
await loadNotebooks()
}, [loadNotebooks])
// CRITICAL: Trigger UI refresh to update notes display
triggerRefresh()
}, [loadNotebooks, triggerRefresh])
// ===== ACTIONS: AI (STUBS) =====
const suggestNotebookForNote = useCallback(async (_noteContent: string) => {

View File

@@ -0,0 +1,14 @@
#!/bin/bash
# Services à corriger
services=(
"lib/ai/services/batch-organization.service.ts"
"lib/ai/services/embedding.service.ts"
"lib/ai/services/auto-label-creation.service.ts"
"lib/ai/services/contextual-auto-tag.service.ts"
"lib/ai/services/notebook-suggestion.service.ts"
"lib/ai/services/notebook-summary.service.ts"
)
echo "Services to fix:"
printf '%s\n' "${services[@]}"

View File

@@ -0,0 +1,69 @@
/**
* Date formatting utilities for recent notes display
*/
/**
* Format a date as relative time in English (e.g., "2 hours ago", "yesterday")
*/
export function formatRelativeTime(date: Date | string): string {
const now = new Date()
const then = new Date(date)
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours} hour${hours > 1 ? 's' : ''} ago`
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `${days} day${days > 1 ? 's' : ''} ago`
}
// For dates older than 7 days, show absolute date
return then.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: then.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
/**
* Format a date as relative time in French (e.g., "il y a 2 heures", "hier")
*/
export function formatRelativeTimeFR(date: Date | string): string {
const now = new Date()
const then = new Date(date)
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
if (seconds < 60) return "à l'instant"
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `il y a ${minutes} minute${minutes > 1 ? 's' : ''}`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `il y a ${hours} heure${hours > 1 ? 's' : ''}`
}
const days = Math.floor(hours / 24)
if (days < 7) {
return `il y a ${days} jour${days > 1 ? 's' : ''}`
}
// For dates older than 7 days, show absolute date
return then.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: then.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}

View File

@@ -441,7 +441,11 @@
"fontSizeExtraLarge": "Extra Large",
"fontSizeDescription": "Adjust the font size for better readability. This applies to all text in the interface.",
"fontSizeUpdateSuccess": "Font size updated successfully",
"fontSizeUpdateFailed": "Failed to update font size"
"fontSizeUpdateFailed": "Failed to update font size",
"showRecentNotes": "Show Recent Notes Section",
"showRecentNotesDescription": "Display recent notes (last 7 days) on the main page",
"recentNotesUpdateSuccess": "Recent notes setting updated successfully",
"recentNotesUpdateFailed": "Failed to update recent notes setting"
},
"aiSettings": {
"title": "AI Settings",

View File

@@ -441,7 +441,11 @@
"fontSizeExtraLarge": "Très grande",
"fontSizeDescription": "Ajustez la taille de la police pour une meilleure lisibilité. Cela s'applique à tout le texte de l'interface.",
"fontSizeUpdateSuccess": "Taille de police mise à jour avec succès",
"fontSizeUpdateFailed": "Échec de la mise à jour de la taille de police"
"fontSizeUpdateFailed": "Échec de la mise à jour de la taille de police",
"showRecentNotes": "Afficher la section Récent",
"showRecentNotesDescription": "Afficher les notes récentes (7 derniers jours) sur la page principale",
"recentNotesUpdateSuccess": "Paramètre des notes récentes mis à jour avec succès",
"recentNotesUpdateFailed": "Échec de la mise à jour du paramètre des notes récentes"
},
"aiSettings": {
"title": "Paramètres IA",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -271,7 +271,8 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
aiProvider: 'aiProvider',
preferredLanguage: 'preferredLanguage',
fontSize: 'fontSize',
demoMode: 'demoMode'
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes'
};
exports.Prisma.SortOrder = {

View File

@@ -13529,6 +13529,7 @@ export namespace Prisma {
preferredLanguage: string | null
fontSize: string | null
demoMode: boolean | null
showRecentNotes: boolean | null
}
export type UserAISettingsMaxAggregateOutputType = {
@@ -13542,6 +13543,7 @@ export namespace Prisma {
preferredLanguage: string | null
fontSize: string | null
demoMode: boolean | null
showRecentNotes: boolean | null
}
export type UserAISettingsCountAggregateOutputType = {
@@ -13555,6 +13557,7 @@ export namespace Prisma {
preferredLanguage: number
fontSize: number
demoMode: number
showRecentNotes: number
_all: number
}
@@ -13570,6 +13573,7 @@ export namespace Prisma {
preferredLanguage?: true
fontSize?: true
demoMode?: true
showRecentNotes?: true
}
export type UserAISettingsMaxAggregateInputType = {
@@ -13583,6 +13587,7 @@ export namespace Prisma {
preferredLanguage?: true
fontSize?: true
demoMode?: true
showRecentNotes?: true
}
export type UserAISettingsCountAggregateInputType = {
@@ -13596,6 +13601,7 @@ export namespace Prisma {
preferredLanguage?: true
fontSize?: true
demoMode?: true
showRecentNotes?: true
_all?: true
}
@@ -13682,6 +13688,7 @@ export namespace Prisma {
preferredLanguage: string
fontSize: string
demoMode: boolean
showRecentNotes: boolean
_count: UserAISettingsCountAggregateOutputType | null
_min: UserAISettingsMinAggregateOutputType | null
_max: UserAISettingsMaxAggregateOutputType | null
@@ -13712,6 +13719,7 @@ export namespace Prisma {
preferredLanguage?: boolean
fontSize?: boolean
demoMode?: boolean
showRecentNotes?: boolean
user?: boolean | UserDefaultArgs<ExtArgs>
}, ExtArgs["result"]["userAISettings"]>
@@ -13726,6 +13734,7 @@ export namespace Prisma {
preferredLanguage?: boolean
fontSize?: boolean
demoMode?: boolean
showRecentNotes?: boolean
user?: boolean | UserDefaultArgs<ExtArgs>
}, ExtArgs["result"]["userAISettings"]>
@@ -13740,6 +13749,7 @@ export namespace Prisma {
preferredLanguage?: boolean
fontSize?: boolean
demoMode?: boolean
showRecentNotes?: boolean
}
export type UserAISettingsInclude<ExtArgs extends $Extensions.InternalArgs = $Extensions.DefaultArgs> = {
@@ -13765,6 +13775,7 @@ export namespace Prisma {
preferredLanguage: string
fontSize: string
demoMode: boolean
showRecentNotes: boolean
}, ExtArgs["result"]["userAISettings"]>
composites: {}
}
@@ -14169,6 +14180,7 @@ export namespace Prisma {
readonly preferredLanguage: FieldRef<"UserAISettings", 'String'>
readonly fontSize: FieldRef<"UserAISettings", 'String'>
readonly demoMode: FieldRef<"UserAISettings", 'Boolean'>
readonly showRecentNotes: FieldRef<"UserAISettings", 'Boolean'>
}
@@ -14695,7 +14707,8 @@ export namespace Prisma {
aiProvider: 'aiProvider',
preferredLanguage: 'preferredLanguage',
fontSize: 'fontSize',
demoMode: 'demoMode'
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes'
};
export type UserAISettingsScalarFieldEnum = (typeof UserAISettingsScalarFieldEnum)[keyof typeof UserAISettingsScalarFieldEnum]
@@ -15728,6 +15741,7 @@ export namespace Prisma {
preferredLanguage?: StringFilter<"UserAISettings"> | string
fontSize?: StringFilter<"UserAISettings"> | string
demoMode?: BoolFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolFilter<"UserAISettings"> | boolean
user?: XOR<UserRelationFilter, UserWhereInput>
}
@@ -15742,6 +15756,7 @@ export namespace Prisma {
preferredLanguage?: SortOrder
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
user?: UserOrderByWithRelationInput
}
@@ -15759,6 +15774,7 @@ export namespace Prisma {
preferredLanguage?: StringFilter<"UserAISettings"> | string
fontSize?: StringFilter<"UserAISettings"> | string
demoMode?: BoolFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolFilter<"UserAISettings"> | boolean
user?: XOR<UserRelationFilter, UserWhereInput>
}, "userId">
@@ -15773,6 +15789,7 @@ export namespace Prisma {
preferredLanguage?: SortOrder
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
_count?: UserAISettingsCountOrderByAggregateInput
_max?: UserAISettingsMaxOrderByAggregateInput
_min?: UserAISettingsMinOrderByAggregateInput
@@ -15792,6 +15809,7 @@ export namespace Prisma {
preferredLanguage?: StringWithAggregatesFilter<"UserAISettings"> | string
fontSize?: StringWithAggregatesFilter<"UserAISettings"> | string
demoMode?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
showRecentNotes?: BoolWithAggregatesFilter<"UserAISettings"> | boolean
}
export type UserCreateInput = {
@@ -16855,6 +16873,7 @@ export namespace Prisma {
preferredLanguage?: string
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
user: UserCreateNestedOneWithoutAiSettingsInput
}
@@ -16869,6 +16888,7 @@ export namespace Prisma {
preferredLanguage?: string
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
}
export type UserAISettingsUpdateInput = {
@@ -16881,6 +16901,7 @@ export namespace Prisma {
preferredLanguage?: StringFieldUpdateOperationsInput | string
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
user?: UserUpdateOneRequiredWithoutAiSettingsNestedInput
}
@@ -16895,6 +16916,7 @@ export namespace Prisma {
preferredLanguage?: StringFieldUpdateOperationsInput | string
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
}
export type UserAISettingsCreateManyInput = {
@@ -16908,6 +16930,7 @@ export namespace Prisma {
preferredLanguage?: string
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
}
export type UserAISettingsUpdateManyMutationInput = {
@@ -16920,6 +16943,7 @@ export namespace Prisma {
preferredLanguage?: StringFieldUpdateOperationsInput | string
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
}
export type UserAISettingsUncheckedUpdateManyInput = {
@@ -16933,6 +16957,7 @@ export namespace Prisma {
preferredLanguage?: StringFieldUpdateOperationsInput | string
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
}
export type StringFilter<$PrismaModel = never> = {
@@ -17789,6 +17814,7 @@ export namespace Prisma {
preferredLanguage?: SortOrder
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
}
export type UserAISettingsMaxOrderByAggregateInput = {
@@ -17802,6 +17828,7 @@ export namespace Prisma {
preferredLanguage?: SortOrder
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
}
export type UserAISettingsMinOrderByAggregateInput = {
@@ -17815,6 +17842,7 @@ export namespace Prisma {
preferredLanguage?: SortOrder
fontSize?: SortOrder
demoMode?: SortOrder
showRecentNotes?: SortOrder
}
export type AccountCreateNestedManyWithoutUserInput = {
@@ -19440,6 +19468,7 @@ export namespace Prisma {
preferredLanguage?: string
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
}
export type UserAISettingsUncheckedCreateWithoutUserInput = {
@@ -19452,6 +19481,7 @@ export namespace Prisma {
preferredLanguage?: string
fontSize?: string
demoMode?: boolean
showRecentNotes?: boolean
}
export type UserAISettingsCreateOrConnectWithoutUserInput = {
@@ -19764,6 +19794,7 @@ export namespace Prisma {
preferredLanguage?: StringFieldUpdateOperationsInput | string
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
}
export type UserAISettingsUncheckedUpdateWithoutUserInput = {
@@ -19776,6 +19807,7 @@ export namespace Prisma {
preferredLanguage?: StringFieldUpdateOperationsInput | string
fontSize?: StringFieldUpdateOperationsInput | string
demoMode?: BoolFieldUpdateOperationsInput | boolean
showRecentNotes?: BoolFieldUpdateOperationsInput | boolean
}
export type UserCreateWithoutAccountsInput = {

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-46efe72656f1c393bbd99fdd6d2d34037b30f693f014757faf08aec1d9319858",
"name": "prisma-client-3d6220144f5583920cbea4466cc4b7cd1590576c45f6d92c95c9ec7f0e8cd94d",
"main": "index.js",
"types": "index.d.ts",
"browser": "index-browser.js",

View File

@@ -271,7 +271,8 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
aiProvider: 'aiProvider',
preferredLanguage: 'preferredLanguage',
fontSize: 'fontSize',
demoMode: 'demoMode'
demoMode: 'demoMode',
showRecentNotes: 'showRecentNotes'
};
exports.Prisma.SortOrder = {

Binary file not shown.

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "UserAISettings" ADD COLUMN "showRecentNotes" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -226,6 +226,7 @@ model UserAISettings {
preferredLanguage String @default("auto")
fontSize String @default("medium")
demoMode Boolean @default(false)
showRecentNotes Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([memoryEcho])

View File

@@ -0,0 +1 @@
{"content":"This is a test note about artificial intelligence and machine learning in Python"}

View File

@@ -0,0 +1 @@
{"text":"This is a test paragraph that needs to be rewritten. It contains multiple sentences and should be improved.","mode":"clarify"}

View File

@@ -1,4 +1,9 @@
{
"status": "passed",
"failedTests": []
"status": "failed",
"failedTests": [
"0e82f3542f319872cf04-73b68b8bffd834564925",
"0e82f3542f319872cf04-17c5a515b5b4a118f4fd",
"0e82f3542f319872cf04-6e4edab6f3b634b94a35",
"0e82f3542f319872cf04-121a19ba6e7e01eeb977"
]
}

View File

@@ -0,0 +1,33 @@
# 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"
- generic [ref=e26] [cursor=pointer]:
- button "Open Next.js Dev Tools" [ref=e27]:
- img [ref=e28]
- generic [ref=e31]:
- button "Open issues overlay" [ref=e32]:
- generic [ref=e33]:
- generic [ref=e34]: "0"
- generic [ref=e35]: "1"
- generic [ref=e36]: Issue
- button "Collapse issues badge" [ref=e37]:
- img [ref=e38]
- alert [ref=e40]
```

View File

@@ -0,0 +1,33 @@
# 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"
- generic [ref=e26] [cursor=pointer]:
- button "Open Next.js Dev Tools" [ref=e27]:
- img [ref=e28]
- generic [ref=e31]:
- button "Open issues overlay" [ref=e32]:
- generic [ref=e33]:
- generic [ref=e34]: "0"
- generic [ref=e35]: "1"
- generic [ref=e36]: Issue
- button "Collapse issues badge" [ref=e37]:
- img [ref=e38]
- alert [ref=e40]
```

View File

@@ -0,0 +1,33 @@
# 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"
- generic [ref=e26] [cursor=pointer]:
- button "Open Next.js Dev Tools" [ref=e27]:
- img [ref=e28]
- generic [ref=e31]:
- button "Open issues overlay" [ref=e32]:
- generic [ref=e33]:
- generic [ref=e34]: "0"
- generic [ref=e35]: "1"
- generic [ref=e36]: Issue
- button "Collapse issues badge" [ref=e37]:
- img [ref=e38]
- alert [ref=e40]
```

View File

@@ -0,0 +1,33 @@
# 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"
- generic [ref=e26] [cursor=pointer]:
- button "Open Next.js Dev Tools" [ref=e27]:
- img [ref=e28]
- generic [ref=e31]:
- button "Open issues overlay" [ref=e32]:
- generic [ref=e33]:
- generic [ref=e34]: "0"
- generic [ref=e35]: "1"
- generic [ref=e36]: Issue
- button "Collapse issues badge" [ref=e37]:
- img [ref=e38]
- alert [ref=e40]
```

View File

@@ -0,0 +1,176 @@
import { test, expect } from '@playwright/test'
test.describe('Bug: Move note to notebook - DIRECT TEST', () => {
test('BUG: Note should disappear from main page after moving to notebook', async ({ page }) => {
const timestamp = Date.now()
// Step 1: Go to homepage
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
// Get initial note count
const notesBefore = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Initial notes count:', notesBefore)
// Step 2: Create a test note
const testNoteTitle = `TEST-BUG-${timestamp}`
const testNoteContent = `Test content ${timestamp}`
console.log('[TEST] Creating note:', testNoteTitle)
await page.click('input[placeholder="Take a note..."]')
await page.fill('input[placeholder="Title"]', testNoteTitle)
await page.fill('textarea[placeholder="Take a note..."]', testNoteContent)
await page.click('button:has-text("Add")')
await page.waitForTimeout(2000)
const notesAfterCreation = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes after creation:', notesAfterCreation)
expect(notesAfterCreation).toBe(notesBefore + 1)
// Step 3: Create a test notebook
console.log('[TEST] Creating notebook')
// Try to find and click "Create Notebook" button
const createNotebookButtons = page.locator('button')
const createButtonsCount = await createNotebookButtons.count()
console.log('[TEST] Total buttons found:', createButtonsCount)
// List all button texts for debugging
for (let i = 0; i < Math.min(createButtonsCount, 10); i++) {
const btn = createNotebookButtons.nth(i)
const btnText = await btn.textContent()
console.log(`[TEST] Button ${i}: "${btnText?.trim()}"`)
}
// Look for a "+" button or "Créer" button
let createBtn = page.locator('button').filter({ hasText: '+' }).first()
if (await createBtn.count() === 0) {
createBtn = page.locator('button').filter({ hasText: 'Créer' }).first()
}
if (await createBtn.count() > 0) {
await createBtn.click()
await page.waitForTimeout(500)
// Try to fill notebook name
const testNotebookName = `TEST-NOTEBOOK-${timestamp}`
// Look for any input field
const inputs = page.locator('input')
const inputCount = await inputs.count()
console.log('[TEST] Input fields found:', inputCount)
// Fill first input with notebook name
if (inputCount > 0) {
const firstInput = inputs.first()
await firstInput.fill(testNotebookName)
// Submit the form
await page.keyboard.press('Enter')
await page.waitForTimeout(2000)
console.log('[TEST] Notebook created (or attempted)')
}
} else {
console.log('[TEST] No create button found!')
}
// Take screenshot to see current state
await page.screenshot({ path: `playwright-report/after-notebook-creation-${timestamp}.png` })
console.log('[TEST] Screenshot saved')
// Step 4: Try to move the note to the notebook using UI
console.log('[TEST] Attempting to move note...')
// Find the note we just created
const ourNote = page.locator('[data-draggable="true"]').filter({ hasText: testNoteTitle })
const ourNoteCount = await ourNote.count()
console.log('[TEST] Our note found:', ourNoteCount)
if (ourNoteCount > 0) {
console.log('[TEST] Found our note, trying to move it')
// Try to find any button/element in the note card
const noteElement = await ourNote.innerHTML()
console.log('[TEST] Note HTML:', noteElement.substring(0, 500))
// Look for any clickable elements in the note
const allButtonsInNote = await ourNote.locator('button').all()
console.log('[TEST] Buttons in note:', allButtonsInNote.length)
for (let i = 0; i < allButtonsInNote.length; i++) {
const btn = allButtonsInNote[i]
const btnText = await btn.textContent()
const btnAriaLabel = await btn.getAttribute('aria-label')
console.log(`[TEST] Note button ${i}: text="${btnText}" aria-label="${btnAriaLabel}"`)
}
// Take screenshot before move attempt
await page.screenshot({ path: `playwright-report/before-move-${timestamp}.png` })
// Try to click any button that might be related to notebooks
if (allButtonsInNote.length > 0) {
// Try clicking the first few buttons
for (let i = 0; i < Math.min(allButtonsInNote.length, 3); i++) {
const btn = allButtonsInNote[i]
const btnText = await btn.textContent()
console.log(`[TEST] Clicking button ${i}: "${btnText}"`)
await btn.click()
await page.waitForTimeout(1000)
// Take screenshot after each click
await page.screenshot({ path: `playwright-report/after-click-${i}-${timestamp}.png` })
// Check if a menu opened
const menuItems = page.locator('[role="menuitem"], [role="option"]')
const menuItemCount = await menuItems.count()
console.log(`[TEST] Menu items after click ${i}:`, menuItemCount)
if (menuItemCount > 0) {
// List menu items
for (let j = 0; j < Math.min(menuItemCount, 5); j++) {
const item = menuItems.nth(j)
const itemText = await item.textContent()
console.log(`[TEST] Menu item ${j}: "${itemText}"`)
}
// Try to click the first menu item (likely our notebook)
const firstMenuItem = menuItems.first()
await firstMenuItem.click()
await page.waitForTimeout(2000)
break
}
// Close menu if any (press Escape)
await page.keyboard.press('Escape')
await page.waitForTimeout(500)
}
}
}
// CRITICAL: Check if note is still visible WITHOUT refresh
await page.waitForTimeout(2000)
const ourNoteAfterMove = await page.locator('[data-draggable="true"]').filter({ hasText: testNoteTitle }).count()
const allNotesAfterMove = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] ===== RESULTS =====')
console.log('[TEST] Our note still visible in main page:', ourNoteAfterMove)
console.log('[TEST] Total notes in main page:', allNotesAfterMove)
// Take final screenshot
await page.screenshot({ path: `playwright-report/final-state-${timestamp}.png` })
console.log('[TEST] Final screenshot saved')
// THE BUG: If ourNoteAfterMove === 1, the note is still visible - triggerRefresh() didn't work!
if (ourNoteAfterMove === 1) {
console.log('[BUG CONFIRMED] triggerRefresh() is NOT working - note still visible!')
console.log('[BUG CONFIRMED] This is the exact bug the user reported')
} else {
console.log('[SUCCESS] Note disappeared from main page - triggerRefresh() worked!')
}
})
})

View File

@@ -0,0 +1,138 @@
import { test, expect } from '@playwright/test'
test.describe('Bug: Note move to notebook - REFRESH ISSUE', () => {
test('should update UI immediately when moving note to notebook WITHOUT page refresh', async ({ page }) => {
const timestamp = Date.now()
// Step 1: Go to homepage (no login needed based on drag-drop tests)
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
// Step 2: Create a test note
const testNoteTitle = `TEST-${timestamp}-Move Note`
const testNoteContent = `This is a test note to verify move bug. Timestamp: ${timestamp}`
console.log('[TEST] Creating test note:', testNoteTitle)
await page.click('input[placeholder="Take a note..."]')
await page.fill('input[placeholder="Title"]', testNoteTitle)
await page.fill('textarea[placeholder="Take a note..."]', testNoteContent)
await page.click('button:has-text("Add")')
await page.waitForTimeout(2000)
// Step 3: Find the created note
const notesBefore = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes count after creation:', notesBefore)
expect(notesBefore).toBeGreaterThan(0)
// Step 4: Get the first note's ID and title
const firstNote = page.locator('[data-draggable="true"]').first()
const noteText = await firstNote.textContent()
console.log('[TEST] First note text:', noteText?.substring(0, 100))
// Step 5: Create a test notebook
console.log('[TEST] Creating test notebook')
const testNotebookName = `TEST-NOTEBOOK-${timestamp}`
// Click the "Create Notebook" button (check for different possible selectors)
const createNotebookBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button:has-text("+")').first()
await createNotebookBtn.click()
await page.waitForTimeout(500)
// Fill notebook name
const notebookInput = page.locator('input[name="name"], input[placeholder*="notebook"], input[placeholder*="nom"]').first()
await notebookInput.fill(testNotebookName)
// Submit
const submitBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button[type="submit"]').first()
await submitBtn.click()
await page.waitForTimeout(2000)
console.log('[TEST] Notebook created:', testNotebookName)
// Step 6: Move the first note to the notebook
console.log('[TEST] Moving note to notebook...')
// Look for a way to move the note - try to find a menu or button on the note
// Try to find a notebook menu or context menu
const notebookMenuBtn = firstNote.locator('button[aria-label*="notebook"], button[title*="notebook"], button:has(.icon-folder)').first()
if (await notebookMenuBtn.count() > 0) {
console.log('[TEST] Found notebook menu button')
await notebookMenuBtn.click()
await page.waitForTimeout(500)
// Select the created notebook
const notebookOption = page.locator(`[role="menuitem"]:has-text("${testNotebookName}")`).first()
if (await notebookOption.count() > 0) {
await notebookOption.click()
console.log('[TEST] Clicked notebook option')
} else {
console.log('[TEST] Notebook option not found in menu')
// List all menu items for debugging
const allMenuItems = await page.locator('[role="menuitem"]').allTextContents()
console.log('[TEST] Available menu items:', allMenuItems)
}
} else {
console.log('[TEST] Notebook menu button not found, trying drag and drop')
// Alternative: Use drag and drop to move note to notebook
const notebooksList = page.locator('[class*="notebook"], [class*="sidebar"]').locator(`text=${testNotebookName}`)
const notebookCount = await notebooksList.count()
console.log('[TEST] Found notebook in list:', notebookCount)
if (notebookCount > 0) {
const notebookElement = notebooksList.first()
await firstNote.dragTo(notebookElement)
console.log('[TEST] Dragged note to notebook')
}
}
// Wait for the move operation to complete
await page.waitForTimeout(3000)
// CRITICAL CHECK: Is the note still visible WITHOUT refresh?
const notesAfterMove = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes count AFTER move (NO REFRESH):', notesAfterMove)
// Get title of first note after move
const firstNoteAfter = page.locator('[data-draggable="true"]').first()
const firstNoteAfterTitle = await firstNoteAfter.locator('[class*="title"], h1, h2, h3').first().textContent()
console.log('[TEST] First note title after move:', firstNoteAfterTitle)
console.log('[TEST] Original note title:', testNoteTitle)
// The bug is: the moved note is STILL VISIBLE in "Notes générales"
// This means the UI did NOT update after the move
if (firstNoteAfterTitle?.includes(testNoteTitle)) {
console.log('[BUG CONFIRMED] Moved note is STILL VISIBLE - UI did not update!')
console.log('[BUG CONFIRMED] This confirms the bug: triggerRefresh() is not working')
// Take a screenshot for debugging
await page.screenshot({ path: `playwright-report/bug-screenshot-${timestamp}.png` })
} else {
console.log('[SUCCESS] Moved note is no longer visible - UI updated correctly!')
}
// Now refresh the page to see what happens
console.log('[TEST] Refreshing page...')
await page.reload()
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
const notesAfterRefresh = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes count AFTER REFRESH:', notesAfterRefresh)
// Check if the note is now gone after refresh
const firstNoteAfterRefresh = await page.locator('[data-draggable="true"]').first()
const firstNoteAfterRefreshTitle = await firstNoteAfterRefresh.locator('[class*="title"], h1, h2, h3').first().textContent()
console.log('[TEST] First note title AFTER REFRESH:', firstNoteAfterRefreshTitle)
if (firstNoteAfterRefreshTitle?.includes(testNoteTitle)) {
console.log('[BUG CONFIRMED] Note is STILL visible after refresh too - move might have failed')
} else {
console.log('[SUCCESS] Note is gone after refresh - it was moved to notebook')
}
})
})

View File

@@ -0,0 +1,68 @@
import { test, expect } from '@playwright/test'
test.describe('Bug: Note move to notebook', () => {
test('should update UI immediately when moving note to notebook', async ({ page }) => {
// Step 1: Login
await page.goto('http://localhost:3000/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password123')
await page.click('button[type="submit"]')
await page.waitForURL('http://localhost:3000/')
// Step 2: Create a test note in "Notes générales"
const testNoteContent = `Test note for move bug ${Date.now()}`
await page.fill('textarea[placeholder*="note"]', testNoteContent)
await page.click('button[type="submit"]')
// Wait for note creation
await page.waitForTimeout(2000)
// Step 3: Find the created note
const notes = await page.locator('.note-card').all()
expect(notes.length).toBeGreaterThan(0)
const firstNote = notes[0]
const noteId = await firstNote.getAttribute('data-note-id')
console.log('Created note with ID:', noteId)
// Step 4: Create a test notebook
await page.click('button:has-text("Créer un notebook")')
await page.fill('input[name="name"]', `Test Notebook ${Date.now()}`)
await page.click('button:has-text("Créer")')
await page.waitForTimeout(2000)
// Step 5: Move the note to the notebook
// Open notebook menu on the note
await firstNote.click('button[aria-label*="notebook"]')
await page.waitForTimeout(500)
// Select the first notebook in the list
const notebookOption = page.locator('[role="menuitem"]').first()
const notebookName = await notebookOption.textContent()
await notebookOption.click()
// Wait for the move operation
await page.waitForTimeout(2000)
// Step 6: Verify the note is NO LONGER visible in "Notes générales"
const notesAfterMove = await page.locator('.note-card').all()
console.log('Notes in "Notes générales" after move:', notesAfterMove.length)
// The note should be gone from "Notes générales"
const movedNote = await page.locator(`.note-card[data-note-id="${noteId}"]`).count()
console.log('Moved note still visible in "Notes générales":', movedNote)
expect(movedNote).toBe(0) // This should pass!
// Step 7: Navigate to the notebook
await page.click(`text="${notebookName}"`)
await page.waitForTimeout(2000)
// Step 8: Verify the note IS visible in the notebook
const notesInNotebook = await page.locator('.note-card').all()
console.log('Notes in notebook:', notesInNotebook.length)
const movedNoteInNotebook = await page.locator(`.note-card[data-note-id="${noteId}"]`).count()
console.log('Moved note visible in notebook:', movedNoteInNotebook)
expect(movedNoteInNotebook).toBe(1) // This should pass!
})
})

View File

@@ -0,0 +1,194 @@
import { test, expect } from '@playwright/test'
test.describe('Bug: Note visibility after creation', () => {
test('should display note immediately after creation in inbox WITHOUT page refresh', async ({ page }) => {
const timestamp = Date.now()
// Step 1: Go to homepage
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
// Step 2: Count notes before creation
const notesBefore = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes count before creation:', notesBefore)
// Step 3: Create a test note in inbox
const testNoteTitle = `TEST-${timestamp}-Visibility Inbox`
const testNoteContent = `This is a test note to verify visibility bug. Timestamp: ${timestamp}`
console.log('[TEST] Creating test note in inbox:', testNoteTitle)
// Click the note input
const noteInput = page.locator('input[placeholder*="Take a note"], textarea[placeholder*="Take a note"]').first()
await noteInput.click()
await page.waitForTimeout(500)
// Fill title if input exists
const titleInput = page.locator('input[placeholder*="Title"], input[name="title"]').first()
if (await titleInput.count() > 0) {
await titleInput.fill(testNoteTitle)
await page.waitForTimeout(300)
}
// Fill content
const contentInput = page.locator('textarea[placeholder*="Take a note"], textarea').first()
await contentInput.fill(testNoteContent)
await page.waitForTimeout(300)
// Submit note
const submitBtn = page.locator('button:has-text("Add"), button:has-text("Ajouter"), button[type="submit"]').first()
await submitBtn.click()
await page.waitForTimeout(2000)
// Step 4: Verify note appears immediately WITHOUT refresh
const notesAfter = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes count after creation (NO REFRESH):', notesAfter)
// Note count should increase
expect(notesAfter).toBeGreaterThan(notesBefore)
// Verify the note is visible with the correct title
const noteCards = page.locator('[data-draggable="true"]')
let noteFound = false
const noteCount = await noteCards.count()
for (let i = 0; i < noteCount; i++) {
const noteText = await noteCards.nth(i).textContent()
if (noteText?.includes(testNoteTitle) || noteText?.includes(testNoteContent.substring(0, 20))) {
noteFound = true
console.log('[SUCCESS] Note found immediately after creation!')
break
}
}
expect(noteFound).toBe(true)
})
test('should display note immediately after creation in notebook WITHOUT page refresh', async ({ page }) => {
const timestamp = Date.now()
// Step 1: Go to homepage
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
// Step 2: Create a test notebook
const testNotebookName = `TEST-NOTEBOOK-${timestamp}`
console.log('[TEST] Creating test notebook:', testNotebookName)
const createNotebookBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button:has-text("+")').first()
await createNotebookBtn.click()
await page.waitForTimeout(500)
const notebookInput = page.locator('input[name="name"], input[placeholder*="notebook"], input[placeholder*="nom"]').first()
await notebookInput.fill(testNotebookName)
const submitNotebookBtn = page.locator('button:has-text("Créer"), button:has-text("Create"), button[type="submit"]').first()
await submitNotebookBtn.click()
await page.waitForTimeout(2000)
// Step 3: Select the notebook
const notebooksList = page.locator('[class*="notebook"], [class*="sidebar"]')
const notebookItem = notebooksList.locator(`text=${testNotebookName}`).first()
await notebookItem.click()
await page.waitForTimeout(2000)
// Step 4: Count notes in notebook before creation
const notesBefore = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes count in notebook before creation:', notesBefore)
// Step 5: Create a test note in the notebook
const testNoteTitle = `TEST-${timestamp}-Visibility Notebook`
const testNoteContent = `This is a test note in notebook. Timestamp: ${timestamp}`
console.log('[TEST] Creating test note in notebook:', testNoteTitle)
// Click the note input
const noteInput = page.locator('input[placeholder*="Take a note"], textarea[placeholder*="Take a note"]').first()
await noteInput.click()
await page.waitForTimeout(500)
// Fill title if input exists
const titleInput = page.locator('input[placeholder*="Title"], input[name="title"]').first()
if (await titleInput.count() > 0) {
await titleInput.fill(testNoteTitle)
await page.waitForTimeout(300)
}
// Fill content
const contentInput = page.locator('textarea[placeholder*="Take a note"], textarea').first()
await contentInput.fill(testNoteContent)
await page.waitForTimeout(300)
// Submit note
const submitBtn = page.locator('button:has-text("Add"), button:has-text("Ajouter"), button[type="submit"]').first()
await submitBtn.click()
await page.waitForTimeout(2000)
// Step 6: Verify note appears immediately WITHOUT refresh
const notesAfter = await page.locator('[data-draggable="true"]').count()
console.log('[TEST] Notes count in notebook after creation (NO REFRESH):', notesAfter)
// Note count should increase
expect(notesAfter).toBeGreaterThan(notesBefore)
// Verify the note is visible with the correct title
const noteCards = page.locator('[data-draggable="true"]')
let noteFound = false
const noteCount = await noteCards.count()
for (let i = 0; i < noteCount; i++) {
const noteText = await noteCards.nth(i).textContent()
if (noteText?.includes(testNoteTitle) || noteText?.includes(testNoteContent.substring(0, 20))) {
noteFound = true
console.log('[SUCCESS] Note found immediately after creation in notebook!')
break
}
}
expect(noteFound).toBe(true)
})
test('should maintain scroll position after note creation', async ({ page }) => {
const timestamp = Date.now()
// Step 1: Go to homepage
await page.goto('/')
await page.waitForLoadState('networkidle')
await page.waitForTimeout(2000)
// Step 2: Scroll down a bit
await page.evaluate(() => window.scrollTo(0, 300))
await page.waitForTimeout(500)
// Get scroll position before
const scrollBefore = await page.evaluate(() => window.scrollY)
console.log('[TEST] Scroll position before creation:', scrollBefore)
// Step 3: Create a test note
const testNoteContent = `TEST-${timestamp}-Scroll Position`
console.log('[TEST] Creating test note...')
const noteInput = page.locator('input[placeholder*="Take a note"], textarea[placeholder*="Take a note"]').first()
await noteInput.click()
await page.waitForTimeout(500)
const contentInput = page.locator('textarea[placeholder*="Take a note"], textarea').first()
await contentInput.fill(testNoteContent)
await page.waitForTimeout(300)
const submitBtn = page.locator('button:has-text("Add"), button:has-text("Ajouter"), button[type="submit"]').first()
await submitBtn.click()
await page.waitForTimeout(2000)
// Step 4: Verify scroll position is maintained (should be similar, not reset to 0)
const scrollAfter = await page.evaluate(() => window.scrollY)
console.log('[TEST] Scroll position after creation:', scrollAfter)
// Scroll position should be maintained (not reset to 0)
// Allow some tolerance for UI updates
expect(Math.abs(scrollAfter - scrollBefore)).toBeLessThan(100)
})
})

View File

@@ -0,0 +1,170 @@
import { test, expect } from '@playwright/test'
test.describe('Favorites Section', () => {
test.beforeEach(async ({ page }) => {
// Navigate to home page
await page.goto('/')
// Wait for page to load
await page.waitForLoadState('networkidle')
})
test('should not show favorites section when no notes are pinned', async ({ page }) => {
// Favorites section should not be present
const favoritesSection = page.locator('[data-testid="favorites-section"]')
await expect(favoritesSection).not.toBeVisible()
})
test('should pin a note and show it in favorites section', async ({ page }) => {
// Check if there are any existing notes
const existingNotes = page.locator('[data-testid="note-card"]')
const noteCount = await existingNotes.count()
if (noteCount === 0) {
// Skip test if no notes exist
test.skip()
return
}
// Pin the first note
const firstNoteCard = existingNotes.first()
await firstNoteCard.hover()
// Find and click the pin button (it's near the top right)
const pinButton = firstNoteCard.locator('button').filter({ hasText: '' }).nth(0)
await pinButton.click()
// Wait for page refresh after pinning
await page.waitForTimeout(2000)
// Favorites section should be visible
const favoritesSection = page.locator('[data-testid="favorites-section"]')
await expect(favoritesSection).toBeVisible()
// Should have section title
const sectionTitle = favoritesSection.locator('h2')
await expect(sectionTitle).toContainText('Pinned')
// Should have at least 1 pinned note
const pinnedNotes = favoritesSection.locator('[data-testid="note-card"]')
await expect(pinnedNotes).toHaveCount(1)
})
test('should unpin a note and remove it from favorites', async ({ page }) => {
// Check if there are any pinned notes already
const favoritesSection = page.locator('[data-testid="favorites-section"]')
const isFavoritesVisible = await favoritesSection.isVisible().catch(() => false)
if (!isFavoritesVisible) {
// Skip test if no favorites exist
test.skip()
return
}
const pinnedNotes = favoritesSection.locator('[data-testid="note-card"]')
const pinnedCount = await pinnedNotes.count()
if (pinnedCount === 0) {
// Skip test if no pinned notes
test.skip()
return
}
// Unpin the first pinned note
const firstPinnedNote = pinnedNotes.first()
await firstPinnedNote.hover()
const pinButton = firstPinnedNote.locator('button').filter({ hasText: '' }).nth(0)
await pinButton.click()
// Wait for page refresh after unpinning
await page.waitForTimeout(2000)
// After unpinning the last pinned note, section should be hidden
const updatedPinnedCount = await favoritesSection.locator('[data-testid="note-card"]').count().catch(() => 0)
const isStillVisible = await favoritesSection.isVisible().catch(() => false)
// If there were only 1 pinned note, section should be hidden
// Otherwise, count should be reduced
if (pinnedCount === 1) {
await expect(isStillVisible).toBe(false)
} else {
await expect(updatedPinnedCount).toBe(pinnedCount - 1)
}
})
test('should show multiple pinned notes in favorites section', async ({ page }) => {
// Check if there are existing notes
const existingNotes = page.locator('[data-testid="note-card"]')
const noteCount = await existingNotes.count()
if (noteCount < 2) {
// Skip test if not enough notes exist
test.skip()
return
}
// Pin first two notes
for (let i = 0; i < 2; i++) {
const noteCard = existingNotes.nth(i)
await noteCard.hover()
const pinButton = noteCard.locator('button').filter({ hasText: '' }).nth(0)
await pinButton.click()
// Wait for page refresh
await page.waitForTimeout(2000)
// Re-query notes after refresh
const refreshedNotes = page.locator('[data-testid="note-card"]')
await refreshedNotes.count()
}
// Favorites section should be visible
const favoritesSection = page.locator('[data-testid="favorites-section"]')
await expect(favoritesSection).toBeVisible()
// Should have 2 pinned notes
const pinnedNotes = favoritesSection.locator('[data-testid="note-card"]')
await expect(pinnedNotes).toHaveCount(2)
})
test('should show favorites section above main notes', async ({ page }) => {
// Check if there are existing notes
const existingNotes = page.locator('[data-testid="note-card"]')
const noteCount = await existingNotes.count()
if (noteCount === 0) {
// Skip test if no notes exist
test.skip()
return
}
// Pin a note
const firstNoteCard = existingNotes.first()
await firstNoteCard.hover()
const pinButton = firstNoteCard.locator('button').filter({ hasText: '' }).nth(0)
await pinButton.click()
// Wait for page refresh
await page.waitForTimeout(2000)
// Both sections should be visible
const favoritesSection = page.locator('[data-testid="favorites-section"]')
const mainNotesGrid = page.locator('[data-testid="notes-grid"]')
await expect(favoritesSection).toBeVisible()
// Main notes grid might be visible only if there are unpinned notes
const hasUnpinnedNotes = await mainNotesGrid.isVisible().catch(() => false)
if (hasUnpinnedNotes) {
// Get Y positions to verify favorites is above
const favoritesY = await favoritesSection.boundingBox()
const mainNotesY = await mainNotesGrid.boundingBox()
if (favoritesY && mainNotesY) {
expect(favoritesY.y).toBeLessThan(mainNotesY.y)
}
}
})
})

View File

@@ -0,0 +1,161 @@
import { test, expect } from '@playwright/test'
test.describe('Recent Notes Section', () => {
test.beforeEach(async ({ page }) => {
// Navigate to home page
await page.goto('/')
// Wait for page to load
await page.waitForLoadState('networkidle')
})
test('should display recent notes section when notes exist', async ({ page }) => {
// Check if recent notes section exists
const recentSection = page.locator('[data-testid="recent-notes-section"]')
// The section should be visible when there are recent notes
// Note: This test assumes there are notes created/modified in the last 7 days
await expect(recentSection).toBeVisible()
})
test('should show section header with clock icon and title', async ({ page }) => {
const recentSection = page.locator('[data-testid="recent-notes-section"]')
// Check for header elements
await expect(recentSection.locator('text=Recent Notes')).toBeVisible()
await expect(recentSection.locator('text=(last 7 days)')).toBeVisible()
})
test('should be collapsible', async ({ page }) => {
const recentSection = page.locator('[data-testid="recent-notes-section"]')
const collapseButton = recentSection.locator('button[aria-expanded]')
// Check that collapse button exists
await expect(collapseButton).toBeVisible()
// Click to collapse
await collapseButton.click()
await expect(collapseButton).toHaveAttribute('aria-expanded', 'false')
// Click to expand
await collapseButton.click()
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true')
})
test('should display notes in grid layout', async ({ page }) => {
const recentSection = page.locator('[data-testid="recent-notes-section"]')
const collapseButton = recentSection.locator('button[aria-expanded]')
// Ensure section is expanded
if (await collapseButton.getAttribute('aria-expanded') === 'false') {
await collapseButton.click()
}
// Check for grid layout
const grid = recentSection.locator('.grid')
await expect(grid).toBeVisible()
// Check that grid has correct classes
await expect(grid).toHaveClass(/grid-cols-1/)
})
test('should not show pinned notes in recent section', async ({ page }) => {
const recentSection = page.locator('[data-testid="recent-notes-section"]')
// Recent notes should filter out pinned notes
// Get all note cards in recent section
const recentNoteCards = recentSection.locator('[data-testid^="note-card-"]')
// If there are recent notes, none should be pinned
const count = await recentNoteCards.count()
if (count > 0) {
// Check that none of the notes in recent section have pin indicator
// This is an indirect check - pinned notes are shown in FavoritesSection
// The implementation should filter them out
const favoriteSection = page.locator('[data-testid="favorites-section"]')
await expect(favoriteSection).toBeVisible()
}
})
test('should handle empty state (no recent notes)', async ({ page }) => {
// This test would need to manipulate the database to ensure no recent notes
// For now, we can check that the section doesn't break when empty
// Reload page to check stability
await page.reload()
await page.waitForLoadState('networkidle')
// Page should load without errors
await expect(page).toHaveTitle(/Keep/)
})
})
test.describe('Recent Notes - Integration', () => {
test('new note should appear in recent section', async ({ page }) => {
// Create a new note
await page.goto('/')
await page.waitForLoadState('networkidle')
const noteContent = `Test note for recent section - ${Date.now()}`
// Type in note input
const noteInput = page.locator('[data-testid="note-input-textarea"]')
await noteInput.fill(noteContent)
// Submit note
const submitButton = page.locator('button[type="submit"]')
await submitButton.click()
// Wait for note to be created
await page.waitForTimeout(1000)
// Reload page to refresh recent notes
await page.reload()
await page.waitForLoadState('networkidle')
// Check if recent notes section is visible
const recentSection = page.locator('[data-testid="recent-notes-section"]')
// The section should now be visible with the new note
await expect(recentSection).toBeVisible()
})
test('editing note should update its position in recent section', async ({ page }) => {
// This test verifies that edited notes move to top
// It requires at least one note to exist
await page.goto('/')
await page.waitForLoadState('networkidle')
const recentSection = page.locator('[data-testid="recent-notes-section"]')
// If recent notes exist
if (await recentSection.isVisible()) {
// Get first note card
const firstNote = recentSection.locator('[data-testid^="note-card-"]').first()
// Click to edit
await firstNote.click()
// Wait for editor to open
const editor = page.locator('[data-testid="note-editor"]')
await expect(editor).toBeVisible()
// Make a small edit
const contentArea = editor.locator('textarea').first()
await contentArea.press('End')
await contentArea.type(' - edited')
// Save changes
const saveButton = editor.locator('button:has-text("Save")').first()
await saveButton.click()
// Wait for save and reload
await page.waitForTimeout(1000)
await page.reload()
await page.waitForLoadState('networkidle')
// The edited note should still be in recent section
await expect(recentSection).toBeVisible()
}
})
})