WIP: Améliorations UX et corrections de bugs avant création des épiques
This commit is contained in:
@@ -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} />
|
||||
|
||||
144
keep-notes/app/(main)/settings/about/page.tsx
Normal file
144
keep-notes/app/(main)/settings/about/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
187
keep-notes/app/(main)/settings/ai/page-new.tsx
Normal file
187
keep-notes/app/(main)/settings/ai/page-new.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
keep-notes/app/(main)/settings/appearance/page.tsx
Normal file
79
keep-notes/app/(main)/settings/appearance/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
keep-notes/app/(main)/settings/data/page.tsx
Normal file
200
keep-notes/app/(main)/settings/data/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
keep-notes/app/(main)/settings/general/page.tsx
Normal file
107
keep-notes/app/(main)/settings/general/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
155
keep-notes/app/(main)/settings/profile/page-new.tsx
Normal file
155
keep-notes/app/(main)/settings/profile/page-new.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { SettingsNav, SettingsSection, SettingToggle, SettingInput, SettingSelect } from '@/components/settings'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export default function ProfileSettingsPage() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Mock user data - in real implementation, load from server
|
||||
const [user, setUser] = useState({
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com'
|
||||
})
|
||||
|
||||
const [language, setLanguage] = useState('auto')
|
||||
const [showRecentNotes, setShowRecentNotes] = useState(false)
|
||||
|
||||
const handleNameChange = async (value: string) => {
|
||||
setUser(prev => ({ ...prev, name: value }))
|
||||
// TODO: Implement profile update
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
382
keep-notes/app/actions/ai-actions.ts
Normal file
382
keep-notes/app/actions/ai-actions.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
53
keep-notes/app/api/notes/delete-all/route.ts
Normal file
53
keep-notes/app/api/notes/delete-all/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
121
keep-notes/app/api/notes/export/route.ts
Normal file
121
keep-notes/app/api/notes/export/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
158
keep-notes/app/api/notes/import/route.ts
Normal file
158
keep-notes/app/api/notes/import/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
59
keep-notes/components/favorites-section.tsx
Normal file
59
keep-notes/components/favorites-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
153
keep-notes/components/recent-notes-section.tsx
Normal file
153
keep-notes/components/recent-notes-section.tsx
Normal 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`
|
||||
}
|
||||
}
|
||||
88
keep-notes/components/settings/SettingInput.tsx
Normal file
88
keep-notes/components/settings/SettingInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
keep-notes/components/settings/SettingSelect.tsx
Normal file
88
keep-notes/components/settings/SettingSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
keep-notes/components/settings/SettingToggle.tsx
Normal file
75
keep-notes/components/settings/SettingToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
keep-notes/components/settings/SettingsNav.tsx
Normal file
89
keep-notes/components/settings/SettingsNav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
keep-notes/components/settings/SettingsSearch.tsx
Normal file
38
keep-notes/components/settings/SettingsSearch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
keep-notes/components/settings/SettingsSection.tsx
Normal file
36
keep-notes/components/settings/SettingsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
keep-notes/components/settings/index.ts
Normal file
6
keep-notes/components/settings/index.ts
Normal 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'
|
||||
@@ -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) => {
|
||||
|
||||
14
keep-notes/fix-services.sh
Normal file
14
keep-notes/fix-services.sh
Normal 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[@]}"
|
||||
69
keep-notes/lib/utils/date.ts
Normal file
69
keep-notes/lib/utils/date.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -271,7 +271,8 @@ exports.Prisma.UserAISettingsScalarFieldEnum = {
|
||||
aiProvider: 'aiProvider',
|
||||
preferredLanguage: 'preferredLanguage',
|
||||
fontSize: 'fontSize',
|
||||
demoMode: 'demoMode'
|
||||
demoMode: 'demoMode',
|
||||
showRecentNotes: 'showRecentNotes'
|
||||
};
|
||||
|
||||
exports.Prisma.SortOrder = {
|
||||
|
||||
34
keep-notes/prisma/client-generated/index.d.ts
vendored
34
keep-notes/prisma/client-generated/index.d.ts
vendored
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-46efe72656f1c393bbd99fdd6d2d34037b30f693f014757faf08aec1d9319858",
|
||||
"name": "prisma-client-3d6220144f5583920cbea4466cc4b7cd1590576c45f6d92c95c9ec7f0e8cd94d",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "index-browser.js",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "UserAISettings" ADD COLUMN "showRecentNotes" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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])
|
||||
|
||||
1
keep-notes/test-ai-tags.json
Normal file
1
keep-notes/test-ai-tags.json
Normal file
@@ -0,0 +1 @@
|
||||
{"content":"This is a test note about artificial intelligence and machine learning in Python"}
|
||||
1
keep-notes/test-reformulate.json
Normal file
1
keep-notes/test-reformulate.json
Normal 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"}
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"0e82f3542f319872cf04-73b68b8bffd834564925",
|
||||
"0e82f3542f319872cf04-17c5a515b5b4a118f4fd",
|
||||
"0e82f3542f319872cf04-6e4edab6f3b634b94a35",
|
||||
"0e82f3542f319872cf04-121a19ba6e7e01eeb977"
|
||||
]
|
||||
}
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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]
|
||||
```
|
||||
176
keep-notes/tests/bug-move-direct.spec.ts
Normal file
176
keep-notes/tests/bug-move-direct.spec.ts
Normal 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!')
|
||||
}
|
||||
})
|
||||
})
|
||||
138
keep-notes/tests/bug-note-move-refresh.spec.ts
Normal file
138
keep-notes/tests/bug-note-move-refresh.spec.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
68
keep-notes/tests/bug-note-move-to-notebook.spec.ts
Normal file
68
keep-notes/tests/bug-note-move-to-notebook.spec.ts
Normal 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!
|
||||
})
|
||||
})
|
||||
194
keep-notes/tests/bug-note-visibility.spec.ts
Normal file
194
keep-notes/tests/bug-note-visibility.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
170
keep-notes/tests/favorites-section.spec.ts
Normal file
170
keep-notes/tests/favorites-section.spec.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
161
keep-notes/tests/recent-notes-section.spec.ts
Normal file
161
keep-notes/tests/recent-notes-section.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user