feat: add reminders page, BMad skills upgrade, MCP server refactor

- Add reminders page with navigation support
- Upgrade BMad builder module to skills-based architecture
- Refactor MCP server: extract tools and auth into separate modules
- Add connections cache, custom AI provider support
- Update prisma schema and generated client
- Various UI/UX improvements and i18n updates
- Add service worker for PWA support

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-04-13 21:02:53 +02:00
parent 18ed116e0d
commit fa7e166f3e
3099 changed files with 397228 additions and 14584 deletions

View File

@@ -2,6 +2,7 @@
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface MetricItem {
title: string
@@ -19,6 +20,8 @@ export interface AdminMetricsProps {
}
export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
const { t } = useLanguage()
return (
<div
className={cn(
@@ -52,7 +55,7 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
{metric.trend.isPositive ? '↑' : '↓'} {Math.abs(metric.trend.value)}%
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
vs last period
{t('admin.metrics.vsLastPeriod')}
</span>
</div>
)}

View File

@@ -4,35 +4,36 @@ import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface AdminSidebarProps {
className?: string
}
export interface NavItem {
title: string
titleKey: string
href: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{
title: 'Dashboard',
titleKey: 'admin.sidebar.dashboard',
href: '/admin',
icon: <LayoutDashboard className="h-5 w-5" />,
},
{
title: 'Users',
titleKey: 'admin.sidebar.users',
href: '/admin/users',
icon: <Users className="h-5 w-5" />,
},
{
title: 'AI Management',
titleKey: 'admin.sidebar.aiManagement',
href: '/admin/ai',
icon: <Brain className="h-5 w-5" />,
},
{
title: 'Settings',
titleKey: 'admin.sidebar.settings',
href: '/admin/settings',
icon: <Settings className="h-5 w-5" />,
},
@@ -40,6 +41,7 @@ const navItems: NavItem[] = [
export function AdminSidebar({ className }: AdminSidebarProps) {
const pathname = usePathname()
const { t } = useLanguage()
return (
<aside
@@ -51,7 +53,7 @@ export function AdminSidebar({ className }: AdminSidebarProps) {
<nav className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href))
return (
<Link
key={item.href}
@@ -65,7 +67,7 @@ export function AdminSidebar({ className }: AdminSidebarProps) {
)}
>
{item.icon}
<span>{item.title}</span>
<span>{t(item.titleKey)}</span>
</Link>
)
})}

View File

@@ -76,7 +76,7 @@ export function AutoLabelSuggestionDialog({
}
} catch (error) {
console.error('Failed to fetch label suggestions:', error)
toast.error('Failed to fetch label suggestions')
toast.error(t('ai.autoLabels.error'))
onOpenChange(false)
} finally {
setLoading(false)
@@ -95,7 +95,7 @@ export function AutoLabelSuggestionDialog({
const handleCreateLabels = async () => {
if (!suggestions || selectedLabels.size === 0) {
toast.error('No labels selected')
toast.error(t('ai.autoLabels.noLabelsSelected'))
return
}
@@ -114,18 +114,15 @@ export function AutoLabelSuggestionDialog({
const data = await response.json()
if (data.success) {
toast.success(
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
`${data.data.createdCount} labels created successfully`
)
toast.success(t('ai.autoLabels.created', { count: data.data.createdCount }))
onLabelsCreated()
onOpenChange(false)
} else {
toast.error(data.error || 'Failed to create labels')
toast.error(data.error || t('ai.autoLabels.error'))
}
} catch (error) {
console.error('Failed to create labels:', error)
toast.error('Failed to create labels')
toast.error(t('ai.autoLabels.error'))
} finally {
setCreating(false)
}
@@ -188,7 +185,7 @@ export function AutoLabelSuggestionDialog({
{t('ai.autoLabels.notesCount', { count: label.count })}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{Math.round(label.confidence * 100)}% confidence
{Math.round(label.confidence * 100)}% {t('notebook.confidence')}
</span>
</div>
</div>

View File

@@ -56,11 +56,11 @@ export function BatchOrganizationDialog({
})
setSelectedNotes(allNoteIds)
} else {
toast.error(data.error || 'Failed to create organization plan')
toast.error(data.error || t('ai.batchOrganization.error'))
}
} catch (error) {
console.error('Failed to create organization plan:', error)
toast.error('Failed to create organization plan')
toast.error(t('ai.batchOrganization.error'))
} finally {
setLoading(false)
}
@@ -108,7 +108,7 @@ export function BatchOrganizationDialog({
const handleApply = async () => {
if (!plan || selectedNotes.size === 0) {
toast.error('No notes selected')
toast.error(t('ai.batchOrganization.noNotesSelected'))
return
}
@@ -127,18 +127,15 @@ export function BatchOrganizationDialog({
const data = await response.json()
if (data.success) {
toast.success(
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
`${data.data.movedCount} notes moved successfully`
)
toast.success(t('ai.batchOrganization.success', { count: data.data.movedCount }))
onNotesMoved()
onOpenChange(false)
} else {
toast.error(data.error || 'Failed to apply organization plan')
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
}
} catch (error) {
console.error('Failed to apply organization plan:', error)
toast.error('Failed to apply organization plan')
toast.error(t('ai.batchOrganization.applyFailed'))
} finally {
setApplying(false)
}
@@ -222,7 +219,7 @@ export function BatchOrganizationDialog({
<Checkbox
checked={allSelected}
onCheckedChange={() => toggleNotebookSelection(notebook)}
aria-label={`Select all notes in ${notebook.notebookName}`}
aria-label={t('ai.batchOrganization.selectAllIn', { notebook: notebook.notebookName })}
/>
<div className="flex items-center gap-2">
<span className="text-xl">{notebook.notebookIcon}</span>
@@ -247,7 +244,7 @@ export function BatchOrganizationDialog({
<Checkbox
checked={selectedNotes.has(note.noteId)}
onCheckedChange={() => toggleNoteSelection(note.noteId)}
aria-label={`Select note: ${note.title || 'Untitled'}`}
aria-label={t('ai.batchOrganization.selectNote', { title: note.title || t('notes.untitled') })}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
@@ -258,7 +255,7 @@ export function BatchOrganizationDialog({
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{Math.round(note.confidence * 100)}% confidence
{Math.round(note.confidence * 100)}% {t('notebook.confidence')}
</span>
{note.reason && (
<span className="text-xs text-muted-foreground">

View File

@@ -114,7 +114,7 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Q4 Marketing Strategy"
placeholder={t('notebook.namePlaceholder')}
className="w-full"
autoFocus
/>

View File

@@ -61,13 +61,13 @@ export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNoteboo
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
{t('notebook.name')}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Notebook"
placeholder={t('notebook.myNotebook')}
className="col-span-3"
autoFocus
/>
@@ -85,7 +85,7 @@ export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNoteboo
type="submit"
disabled={!name.trim() || isSubmitting}
>
{isSubmitting ? 'Saving...' : t('general.confirm')}
{isSubmitting ? t('notebook.saving') : t('general.confirm')}
</Button>
</DialogFooter>
</form>

View File

@@ -119,7 +119,13 @@ export function Header({
// Skip if search hasn't changed or if we already pushed this value
if (debouncedSearchQuery === lastPushedSearch.current) return
// Build new params preserving other filters
// Only trigger search navigation from the home page
if (pathname !== '/') {
lastPushedSearch.current = debouncedSearchQuery
return
}
// Build new params preserving other filters (notebook, labels, etc.)
const params = new URLSearchParams(searchParams.toString())
if (debouncedSearchQuery.trim()) {
params.set('search', debouncedSearchQuery)
@@ -132,6 +138,7 @@ export function Header({
// Mark as pushed before calling router.push to prevent loops
lastPushedSearch.current = debouncedSearchQuery
router.push(newUrl)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchQuery])
// Handle semantic search button click
@@ -303,15 +310,15 @@ export function Header({
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
</div>
{/* Search Bar - Style Keep */}
{/* Search Bar */}
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
<div className="flex w-full flex-1 items-stretch rounded-xl h-full bg-slate-100 dark:bg-slate-800 focus-within:ring-2 focus-within:ring-primary/20 transition-all">
<div className="text-slate-500 dark:text-slate-400 flex items-center justify-center pl-4">
<Search className="w-5 h-5" />
<div className="flex w-full flex-1 items-stretch rounded-full h-full bg-slate-100 dark:bg-slate-800 border border-transparent focus-within:bg-white dark:focus-within:bg-slate-700 focus-within:border-primary/30 focus-within:shadow-md transition-all duration-200">
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
<Search className="w-4 h-4" />
</div>
<input
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-500 dark:placeholder:text-slate-400 px-3 text-sm font-medium focus:ring-0"
placeholder={t('search.placeholder') || "Search notes, labels, and more..."}
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
placeholder={t('search.placeholder') || "Rechercher..."}
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}

View File

@@ -76,11 +76,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
})
// Show success message and open modal
toast.success('Opening connection...')
toast.success(t('toast.openingConnection'))
setShowModal(true)
} catch (error) {
console.error('[MemoryEcho] Failed to view connection:', error)
toast.error('Failed to open connection')
toast.error(t('toast.openConnectionFailed'))
}
}
@@ -100,16 +100,16 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
// Show feedback toast
if (feedback === 'thumbs_up') {
toast.success('Thanks for your feedback!')
toast.success(t('toast.thanksFeedback'))
} else {
toast.success('Thanks! We\'ll use this to improve.')
toast.success(t('toast.thanksFeedbackImproving'))
}
// Dismiss notification
setIsDismissed(true)
} catch (error) {
console.error('[MemoryEcho] Failed to submit feedback:', error)
toast.error('Failed to submit feedback')
toast.error(t('toast.feedbackFailed'))
}
}
@@ -123,8 +123,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
}
// Calculate values for both notification and modal
const note1Title = insight.note1.title || 'Untitled'
const note2Title = insight.note2.title || 'Untitled'
const note1Title = insight.note1.title || t('notification.untitled')
const note2Title = insight.note2.title || t('notification.untitled')
const similarityPercentage = Math.round(insight.similarityScore * 100)
// Render modal if requested
@@ -286,7 +286,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
{note2Title}
</Badge>
<Badge variant="secondary" className="ml-auto text-xs">
{similarityPercentage}% match
{t('memoryEcho.match', { percentage: similarityPercentage })}
</Badge>
</div>
</div>

View File

@@ -85,7 +85,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
const [currentReminder, setCurrentReminder] = useState<Date | null>(
note.reminder ? new Date(note.reminder as unknown as string) : null
)
// Link state
const [showLinkDialog, setShowLinkDialog] = useState(false)
@@ -325,12 +327,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
body: JSON.stringify({ text: content, option: 'clarify' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to clarify')
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Clarify error:', error)
toast.error(t('ai.reformulationFailed'))
toast.error(t('notes.clarifyFailed'))
} finally {
setIsProcessingAI(false)
}
@@ -351,12 +353,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
body: JSON.stringify({ text: content, option: 'shorten' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to shorten')
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Shorten error:', error)
toast.error(t('ai.reformulationFailed'))
toast.error(t('notes.shortenFailed'))
} finally {
setIsProcessingAI(false)
}
@@ -377,12 +379,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
body: JSON.stringify({ text: content, option: 'improve' })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to improve')
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
setContent(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
console.error('Improve error:', error)
toast.error(t('ai.reformulationFailed'))
toast.error(t('notes.improveFailed'))
} finally {
setIsProcessingAI(false)
}
@@ -408,7 +410,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
body: JSON.stringify({ text: content })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to transform')
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
// Set the transformed markdown content and enable markdown mode
setContent(data.transformedText)
@@ -440,18 +442,28 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
toast.success(t('ai.reformulationApplied'))
}
const handleReminderSave = (date: Date) => {
const handleReminderSave = async (date: Date) => {
if (date < new Date()) {
toast.error(t('notes.reminderPastError'))
return
}
setCurrentReminder(date)
toast.success(t('notes.reminderSet', { date: date.toLocaleString() }))
try {
await updateNote(note.id, { reminder: date })
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
} catch {
toast.error(t('notebook.savingReminder'))
}
}
const handleRemoveReminder = () => {
const handleRemoveReminder = async () => {
setCurrentReminder(null)
toast.success(t('notes.reminderRemoved'))
try {
await updateNote(note.id, { reminder: null })
toast.success(t('notes.reminderRemoved'))
} catch {
toast.error(t('notebook.removingReminder'))
}
}
const handleSave = async () => {

View File

@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Loader2, FileText, RefreshCw } from 'lucide-react'
import { Loader2, FileText, RefreshCw, Download } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import type { NotebookSummary } from '@/lib/ai/services'
@@ -81,10 +81,79 @@ export function NotebookSummaryDialog({
setRegenerating(false)
}
const handleExportPDF = () => {
if (!summary) return
const printWindow = window.open('', '_blank')
if (!printWindow) return
const date = new Date(summary.generatedAt).toLocaleString()
const labels = summary.stats.labelsUsed.length > 0
? `<p><strong>${t('notebook.labels')}</strong> ${summary.stats.labelsUsed.join(', ')}</p>`
: ''
printWindow.document.write(`<!DOCTYPE html>
<html lang="${document.documentElement.lang || 'en'}">
<head>
<meta charset="UTF-8" />
<title>${t('notebook.pdfTitle', { name: summary.notebookName })}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Georgia, 'Times New Roman', serif; font-size: 13pt; line-height: 1.7; color: #111; padding: 2.5cm 3cm; max-width: 800px; margin: 0 auto; }
h1 { font-size: 20pt; font-weight: bold; margin-bottom: 0.25em; }
.meta { font-size: 10pt; color: #666; margin-bottom: 1.5em; border-bottom: 1px solid #ddd; padding-bottom: 0.75em; }
.meta p { margin: 0.2em 0; }
.content h1, .content h2, .content h3 { margin-top: 1.2em; margin-bottom: 0.4em; font-family: sans-serif; }
.content h2 { font-size: 15pt; }
.content h3 { font-size: 13pt; }
.content p { margin-bottom: 0.8em; }
.content ul, .content ol { margin-left: 1.5em; margin-bottom: 0.8em; }
.content li { margin-bottom: 0.3em; }
.content strong { font-weight: bold; }
.content em { font-style: italic; }
.content code { font-family: monospace; background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
.content blockquote { border-left: 3px solid #ccc; padding-left: 1em; color: #555; margin: 0.8em 0; }
@media print {
body { padding: 1cm 1.5cm; }
@page { margin: 1.5cm; }
}
</style>
</head>
<body>
<h1>${t('notebook.pdfTitle', { name: summary.notebookName })}</h1>
<div class="meta">
<p><strong>${t('notebook.pdfNotesLabel')}</strong> ${summary.stats.totalNotes}</p>
${labels}
<p><strong>${t('notebook.pdfGeneratedOn')}</strong> ${date}</p>
</div>
<div class="content">
${summary.summary
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
.replace(/^[-*] (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
.replace(/\n\n/g, '</p><p>')
.replace(/^(?!<[hHuUoOblp])(.+)$/gm, '<p>$1</p>')}
</div>
</body>
</html>`)
printWindow.document.close()
printWindow.focus()
setTimeout(() => {
printWindow.print()
}, 300)
}
if (loading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogContent className="overflow-y-auto" style={{ maxWidth: 'min(48rem, 95vw)', maxHeight: '90vh' }}>
<DialogHeader className="sr-only">
<DialogTitle>{t('notebook.generating')}</DialogTitle>
<DialogDescription>{t('notebook.generatingDescription') || 'Please wait...'}</DialogDescription>
@@ -106,56 +175,69 @@ export function NotebookSummaryDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between gap-4">
<DialogContent
className="flex flex-col overflow-hidden p-0"
style={{ maxWidth: 'min(64rem, 95vw)', height: '90vh' }}
>
{/* En-tête fixe */}
<div className="flex-shrink-0 px-6 pt-6 pb-4 border-b">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5" />
{t('notebook.summary')}
<span className="text-lg font-semibold">{t('notebook.summary')}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleRegenerate}
disabled={regenerating}
className="gap-2"
>
<RefreshCw className={`h-4 w-4 ${regenerating ? 'animate-spin' : ''}`} />
{regenerating
? (t('ai.notebookSummary.regenerating') || 'Regenerating...')
: (t('ai.notebookSummary.regenerate') || 'Regenerate')}
</Button>
</DialogTitle>
<DialogDescription>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExportPDF} className="gap-2">
<Download className="h-4 w-4" />
{t('ai.notebookSummary.exportPDF') || 'Exporter PDF'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRegenerate}
disabled={regenerating}
className="gap-2"
>
<RefreshCw className={`h-4 w-4 ${regenerating ? 'animate-spin' : ''}`} />
{regenerating
? (t('ai.notebookSummary.regenerating') || 'Regenerating...')
: (t('ai.notebookSummary.regenerate') || 'Regenerate')}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground mt-1">
{t('notebook.summaryDescription', {
notebook: summary.notebookName,
count: summary.stats.totalNotes,
})}
</DialogDescription>
</DialogHeader>
{/* Stats */}
<div className="flex flex-wrap gap-4 p-4 bg-muted rounded-lg">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{summary.stats.totalNotes} {summary.stats.totalNotes === 1 ? 'note' : 'notes'}
</span>
</div>
{summary.stats.labelsUsed.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Labels:</span>
<span className="text-sm">{summary.stats.labelsUsed.join(', ')}</span>
</div>
)}
<div className="ml-auto text-xs text-muted-foreground">
{new Date(summary.generatedAt).toLocaleString()}
</div>
</p>
</div>
{/* Summary Content */}
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{summary.summary}</ReactMarkdown>
{/* Zone scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Stats */}
<div className="flex flex-wrap gap-4 p-4 bg-muted rounded-lg">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">
{t('ai.autoLabels.notesCount', { count: summary.stats.totalNotes })}
</span>
</div>
{summary.stats.labelsUsed.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('notebook.labels')}</span>
<span className="text-sm">{summary.stats.labelsUsed.join(', ')}</span>
</div>
)}
<div className="ml-auto text-xs text-muted-foreground">
{new Date(summary.generatedAt).toLocaleString()}
</div>
</div>
{/* Contenu Markdown */}
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown>{summary.summary}</ReactMarkdown>
</div>
</div>
</DialogContent>
</Dialog>

View File

@@ -130,7 +130,7 @@ export function NotificationPanel() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
<span className="font-semibold text-sm">{t('nav.aiSettings')}</span>
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
</div>
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
@@ -166,7 +166,7 @@ export function NotificationPanel() {
{request.sharer.name || request.sharer.email}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
shared &quot;{request.note.title || 'Untitled'}&quot;
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
</p>
</div>
<Badge

View File

@@ -0,0 +1,232 @@
'use client'
import { useState, useTransition } from 'react'
import { Bell, BellOff, CheckCircle2, Circle, Clock, AlertCircle, RefreshCw } from 'lucide-react'
import { Note } from '@/lib/types'
import { toggleReminderDone } from '@/app/actions/notes'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import { useRouter } from 'next/navigation'
interface RemindersPageProps {
notes: Note[]
}
function formatReminderDate(date: Date | string, t: (key: string, params?: Record<string, string | number>) => string, locale = 'fr-FR'): string {
const d = new Date(date)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const noteDay = new Date(d.getFullYear(), d.getMonth(), d.getDate())
const timeStr = d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
if (noteDay.getTime() === today.getTime()) return t('reminders.todayAt', { time: timeStr })
if (noteDay.getTime() === tomorrow.getTime()) return t('reminders.tomorrowAt', { time: timeStr })
return d.toLocaleDateString(locale, {
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
})
}
function ReminderCard({ note, onToggleDone, t }: { note: Note; onToggleDone: (id: string, done: boolean) => void; t: (key: string, params?: Record<string, string | number>) => string }) {
const now = new Date()
const reminderDate = note.reminder ? new Date(note.reminder) : null
const isOverdue = reminderDate && reminderDate < now && !note.isReminderDone
const isDone = note.isReminderDone
return (
<div
className={cn(
'group relative flex items-start gap-4 rounded-2xl border p-4 transition-all duration-200',
isDone
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 opacity-60'
: isOverdue
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20'
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-[#1e2128] hover:shadow-md'
)}
>
{/* Done toggle */}
<button
onClick={() => onToggleDone(note.id, !isDone)}
className={cn(
'mt-0.5 flex-none transition-colors',
isDone
? 'text-green-500 hover:text-slate-400'
: 'text-slate-300 hover:text-green-500 dark:text-slate-600'
)}
title={isDone ? t('reminders.markUndone') : t('reminders.markDone')}
>
{isDone ? (
<CheckCircle2 className="w-5 h-5" />
) : (
<Circle className="w-5 h-5" />
)}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
{note.title && (
<p className={cn('font-semibold text-slate-900 dark:text-white truncate', isDone && 'line-through opacity-60')}>
{note.title}
</p>
)}
<p className={cn('text-sm text-slate-600 dark:text-slate-300 line-clamp-2 mt-0.5', isDone && 'line-through opacity-60')}>
{note.content}
</p>
{/* Reminder date badge */}
{reminderDate && (
<div className={cn(
'inline-flex items-center gap-1.5 mt-2 px-2.5 py-1 rounded-full text-xs font-medium',
isDone
? 'bg-slate-100 dark:bg-slate-700 text-slate-500'
: isOverdue
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-400'
: 'bg-primary/10 text-primary'
)}>
{isOverdue && !isDone ? (
<AlertCircle className="w-3 h-3" />
) : (
<Clock className="w-3 h-3" />
)}
{formatReminderDate(reminderDate, t)}
{note.reminderRecurrence && (
<span className="ml-1 opacity-70">· {note.reminderRecurrence}</span>
)}
</div>
)}
</div>
</div>
)
}
function SectionTitle({ icon: Icon, label, count, color }: { icon: any; label: string; count: number; color: string }) {
return (
<div className={cn('flex items-center gap-2 mb-3', color)}>
<Icon className="w-4 h-4" />
<h2 className="text-sm font-semibold uppercase tracking-wider">{label}</h2>
<span className="ml-auto text-xs font-medium bg-current/10 px-2 py-0.5 rounded-full opacity-70">{count}</span>
</div>
)
}
export function RemindersPage({ notes: initialNotes }: RemindersPageProps) {
const { t } = useLanguage()
const router = useRouter()
const [notes, setNotes] = useState(initialNotes)
const [isPending, startTransition] = useTransition()
const now = new Date()
const upcoming = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) >= now)
const overdue = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) < now)
const done = notes.filter(n => n.isReminderDone)
const handleToggleDone = (noteId: string, newDone: boolean) => {
// Optimistic update
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, isReminderDone: newDone } : n))
startTransition(async () => {
await toggleReminderDone(noteId, newDone)
router.refresh()
})
}
if (notes.length === 0) {
return (
<main className="container mx-auto px-4 py-12 max-w-3xl">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
<Bell className="w-8 h-8 text-primary" />
{t('reminders.title') || 'Rappels'}
</h1>
</div>
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center text-slate-500 dark:text-slate-400">
<div className="bg-slate-100 dark:bg-slate-800 p-6 rounded-full mb-4">
<BellOff className="w-12 h-12 text-slate-400" />
</div>
<h2 className="text-xl font-semibold mb-2 text-slate-700 dark:text-slate-300">
{t('reminders.empty') || 'Aucun rappel'}
</h2>
<p className="max-w-sm text-sm opacity-80">
{t('reminders.emptyDescription') || 'Ajoutez un rappel à une note pour le retrouver ici.'}
</p>
</div>
</main>
)
}
return (
<main className="container mx-auto px-4 py-8 max-w-3xl">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
<Bell className="w-8 h-8 text-primary" />
{t('reminders.title') || 'Rappels'}
</h1>
{isPending && (
<RefreshCw className="w-4 h-4 animate-spin text-slate-400" />
)}
</div>
<div className="space-y-8">
{/* En retard */}
{overdue.length > 0 && (
<section>
<SectionTitle
icon={AlertCircle}
label={t('reminders.overdue') || 'En retard'}
count={overdue.length}
color="text-amber-600 dark:text-amber-400"
/>
<div className="space-y-3">
{overdue.map(note => (
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
))}
</div>
</section>
)}
{/* À venir */}
{upcoming.length > 0 && (
<section>
<SectionTitle
icon={Clock}
label={t('reminders.upcoming') || 'À venir'}
count={upcoming.length}
color="text-primary"
/>
<div className="space-y-3">
{upcoming.map(note => (
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
))}
</div>
</section>
)}
{/* Terminés */}
{done.length > 0 && (
<section>
<SectionTitle
icon={CheckCircle2}
label={t('reminders.done') || 'Terminés'}
count={done.length}
color="text-green-600 dark:text-green-400"
/>
<div className="space-y-3">
{done.map(note => (
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
))}
</div>
</section>
)}
</div>
</main>
)
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
import { Search, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface Section {
id: string
@@ -23,12 +24,15 @@ interface SettingsSearchProps {
export function SettingsSearch({
sections,
onFilter,
placeholder = 'Search settings...',
placeholder,
className
}: SettingsSearchProps) {
const { t } = useLanguage()
const [query, setQuery] = useState('')
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
const searchPlaceholder = placeholder || t('settings.searchNoResults') || 'Search settings...'
useEffect(() => {
if (!query.trim()) {
setFilteredSections(sections)
@@ -77,7 +81,7 @@ export function SettingsSearch({
type="text"
value={query}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder={placeholder}
placeholder={searchPlaceholder}
className="pl-10"
autoFocus
/>
@@ -86,14 +90,14 @@ export function SettingsSearch({
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search"
aria-label={t('search.placeholder')}
>
<X className="h-4 w-4" />
</button>
)}
{isEmptySearch && (
<div className="absolute top-full left-0 right-0 mt-1 p-2 bg-white rounded-lg shadow-lg border z-50">
<p className="text-sm text-gray-600">No settings found</p>
<p className="text-sm text-gray-600">{t('settings.searchNoResults')}</p>
</div>
)}
</div>