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:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface MetricItem {
|
||||
title: string
|
||||
@@ -19,6 +20,8 @@ export interface AdminMetricsProps {
|
||||
}
|
||||
|
||||
export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -52,7 +55,7 @@ export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
|
||||
{metric.trend.isPositive ? '↑' : '↓'} {Math.abs(metric.trend.value)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
vs last period
|
||||
{t('admin.metrics.vsLastPeriod')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,35 +4,36 @@ import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { LayoutDashboard, Users, Brain, Settings } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface AdminSidebarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
title: string
|
||||
titleKey: string
|
||||
href: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
titleKey: 'admin.sidebar.dashboard',
|
||||
href: '/admin',
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
titleKey: 'admin.sidebar.users',
|
||||
href: '/admin/users',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'AI Management',
|
||||
titleKey: 'admin.sidebar.aiManagement',
|
||||
href: '/admin/ai',
|
||||
icon: <Brain className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
titleKey: 'admin.sidebar.settings',
|
||||
href: '/admin/settings',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
},
|
||||
@@ -40,6 +41,7 @@ const navItems: NavItem[] = [
|
||||
|
||||
export function AdminSidebar({ className }: AdminSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<aside
|
||||
@@ -51,7 +53,7 @@ export function AdminSidebar({ className }: AdminSidebarProps) {
|
||||
<nav className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || (item.href !== '/admin' && pathname?.startsWith(item.href))
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
@@ -65,7 +67,7 @@ export function AdminSidebar({ className }: AdminSidebarProps) {
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.title}</span>
|
||||
<span>{t(item.titleKey)}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function AutoLabelSuggestionDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch label suggestions:', error)
|
||||
toast.error('Failed to fetch label suggestions')
|
||||
toast.error(t('ai.autoLabels.error'))
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -95,7 +95,7 @@ export function AutoLabelSuggestionDialog({
|
||||
|
||||
const handleCreateLabels = async () => {
|
||||
if (!suggestions || selectedLabels.size === 0) {
|
||||
toast.error('No labels selected')
|
||||
toast.error(t('ai.autoLabels.noLabelsSelected'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -114,18 +114,15 @@ export function AutoLabelSuggestionDialog({
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.autoLabels.created', { count: data.data.createdCount }) ||
|
||||
`${data.data.createdCount} labels created successfully`
|
||||
)
|
||||
toast.success(t('ai.autoLabels.created', { count: data.data.createdCount }))
|
||||
onLabelsCreated()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to create labels')
|
||||
toast.error(data.error || t('ai.autoLabels.error'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create labels:', error)
|
||||
toast.error('Failed to create labels')
|
||||
toast.error(t('ai.autoLabels.error'))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@@ -188,7 +185,7 @@ export function AutoLabelSuggestionDialog({
|
||||
{t('ai.autoLabels.notesCount', { count: label.count })}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{Math.round(label.confidence * 100)}% confidence
|
||||
{Math.round(label.confidence * 100)}% {t('notebook.confidence')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,11 +56,11 @@ export function BatchOrganizationDialog({
|
||||
})
|
||||
setSelectedNotes(allNoteIds)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to create organization plan')
|
||||
toast.error(data.error || t('ai.batchOrganization.error'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create organization plan:', error)
|
||||
toast.error('Failed to create organization plan')
|
||||
toast.error(t('ai.batchOrganization.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function BatchOrganizationDialog({
|
||||
|
||||
const handleApply = async () => {
|
||||
if (!plan || selectedNotes.size === 0) {
|
||||
toast.error('No notes selected')
|
||||
toast.error(t('ai.batchOrganization.noNotesSelected'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -127,18 +127,15 @@ export function BatchOrganizationDialog({
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success(
|
||||
t('ai.batchOrganization.success', { count: data.data.movedCount }) ||
|
||||
`${data.data.movedCount} notes moved successfully`
|
||||
)
|
||||
toast.success(t('ai.batchOrganization.success', { count: data.data.movedCount }))
|
||||
onNotesMoved()
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to apply organization plan')
|
||||
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to apply organization plan:', error)
|
||||
toast.error('Failed to apply organization plan')
|
||||
toast.error(t('ai.batchOrganization.applyFailed'))
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
@@ -222,7 +219,7 @@ export function BatchOrganizationDialog({
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={() => toggleNotebookSelection(notebook)}
|
||||
aria-label={`Select all notes in ${notebook.notebookName}`}
|
||||
aria-label={t('ai.batchOrganization.selectAllIn', { notebook: notebook.notebookName })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{notebook.notebookIcon}</span>
|
||||
@@ -247,7 +244,7 @@ export function BatchOrganizationDialog({
|
||||
<Checkbox
|
||||
checked={selectedNotes.has(note.noteId)}
|
||||
onCheckedChange={() => toggleNoteSelection(note.noteId)}
|
||||
aria-label={`Select note: ${note.title || 'Untitled'}`}
|
||||
aria-label={t('ai.batchOrganization.selectNote', { title: note.title || t('notes.untitled') })}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
@@ -258,7 +255,7 @@ export function BatchOrganizationDialog({
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{Math.round(note.confidence * 100)}% confidence
|
||||
{Math.round(note.confidence * 100)}% {t('notebook.confidence')}
|
||||
</span>
|
||||
{note.reason && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -114,7 +114,7 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Q4 Marketing Strategy"
|
||||
placeholder={t('notebook.namePlaceholder')}
|
||||
className="w-full"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -61,13 +61,13 @@ export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNoteboo
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
{t('notebook.name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Notebook"
|
||||
placeholder={t('notebook.myNotebook')}
|
||||
className="col-span-3"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -85,7 +85,7 @@ export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNoteboo
|
||||
type="submit"
|
||||
disabled={!name.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : t('general.confirm')}
|
||||
{isSubmitting ? t('notebook.saving') : t('general.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -119,7 +119,13 @@ export function Header({
|
||||
// Skip if search hasn't changed or if we already pushed this value
|
||||
if (debouncedSearchQuery === lastPushedSearch.current) return
|
||||
|
||||
// Build new params preserving other filters
|
||||
// Only trigger search navigation from the home page
|
||||
if (pathname !== '/') {
|
||||
lastPushedSearch.current = debouncedSearchQuery
|
||||
return
|
||||
}
|
||||
|
||||
// Build new params preserving other filters (notebook, labels, etc.)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
params.set('search', debouncedSearchQuery)
|
||||
@@ -132,6 +138,7 @@ export function Header({
|
||||
// Mark as pushed before calling router.push to prevent loops
|
||||
lastPushedSearch.current = debouncedSearchQuery
|
||||
router.push(newUrl)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchQuery])
|
||||
|
||||
// Handle semantic search button click
|
||||
@@ -303,15 +310,15 @@ export function Header({
|
||||
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
|
||||
</div>
|
||||
|
||||
{/* Search Bar - Style Keep */}
|
||||
{/* Search Bar */}
|
||||
<label className="hidden md:flex flex-col min-w-40 w-96 !h-10">
|
||||
<div className="flex w-full flex-1 items-stretch rounded-xl h-full bg-slate-100 dark:bg-slate-800 focus-within:ring-2 focus-within:ring-primary/20 transition-all">
|
||||
<div className="text-slate-500 dark:text-slate-400 flex items-center justify-center pl-4">
|
||||
<Search className="w-5 h-5" />
|
||||
<div className="flex w-full flex-1 items-stretch rounded-full h-full bg-slate-100 dark:bg-slate-800 border border-transparent focus-within:bg-white dark:focus-within:bg-slate-700 focus-within:border-primary/30 focus-within:shadow-md transition-all duration-200">
|
||||
<div className="text-slate-400 dark:text-slate-400 flex items-center justify-center pl-4 focus-within:text-primary transition-colors">
|
||||
<Search className="w-4 h-4" />
|
||||
</div>
|
||||
<input
|
||||
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden rounded-xl bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-500 dark:placeholder:text-slate-400 px-3 text-sm font-medium focus:ring-0"
|
||||
placeholder={t('search.placeholder') || "Search notes, labels, and more..."}
|
||||
className="form-input flex w-full min-w-0 flex-1 resize-none overflow-hidden bg-transparent border-none text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 px-3 text-sm focus:ring-0 focus:outline-none"
|
||||
placeholder={t('search.placeholder') || "Rechercher..."}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
|
||||
@@ -76,11 +76,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
})
|
||||
|
||||
// Show success message and open modal
|
||||
toast.success('Opening connection...')
|
||||
toast.success(t('toast.openingConnection'))
|
||||
setShowModal(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to view connection:', error)
|
||||
toast.error('Failed to open connection')
|
||||
toast.error(t('toast.openConnectionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,16 +100,16 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
// Show feedback toast
|
||||
if (feedback === 'thumbs_up') {
|
||||
toast.success('Thanks for your feedback!')
|
||||
toast.success(t('toast.thanksFeedback'))
|
||||
} else {
|
||||
toast.success('Thanks! We\'ll use this to improve.')
|
||||
toast.success(t('toast.thanksFeedbackImproving'))
|
||||
}
|
||||
|
||||
// Dismiss notification
|
||||
setIsDismissed(true)
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to submit feedback:', error)
|
||||
toast.error('Failed to submit feedback')
|
||||
toast.error(t('toast.feedbackFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +123,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
}
|
||||
|
||||
// Calculate values for both notification and modal
|
||||
const note1Title = insight.note1.title || 'Untitled'
|
||||
const note2Title = insight.note2.title || 'Untitled'
|
||||
const note1Title = insight.note1.title || t('notification.untitled')
|
||||
const note2Title = insight.note2.title || t('notification.untitled')
|
||||
const similarityPercentage = Math.round(insight.similarityScore * 100)
|
||||
|
||||
// Render modal if requested
|
||||
@@ -286,7 +286,7 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
{note2Title}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="ml-auto text-xs">
|
||||
{similarityPercentage}% match
|
||||
{t('memoryEcho.match', { percentage: similarityPercentage })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
|
||||
// Reminder state
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(note.reminder)
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
||||
note.reminder ? new Date(note.reminder as unknown as string) : null
|
||||
)
|
||||
|
||||
// Link state
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||
@@ -325,12 +327,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content, option: 'clarify' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to clarify')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Clarify error:', error)
|
||||
toast.error(t('ai.reformulationFailed'))
|
||||
toast.error(t('notes.clarifyFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
@@ -351,12 +353,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content, option: 'shorten' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to shorten')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Shorten error:', error)
|
||||
toast.error(t('ai.reformulationFailed'))
|
||||
toast.error(t('notes.shortenFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
@@ -377,12 +379,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content, option: 'improve' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to improve')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Improve error:', error)
|
||||
toast.error(t('ai.reformulationFailed'))
|
||||
toast.error(t('notes.improveFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
@@ -408,7 +410,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
body: JSON.stringify({ text: content })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || 'Failed to transform')
|
||||
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
||||
|
||||
// Set the transformed markdown content and enable markdown mode
|
||||
setContent(data.transformedText)
|
||||
@@ -440,18 +442,28 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
}
|
||||
|
||||
const handleReminderSave = (date: Date) => {
|
||||
const handleReminderSave = async (date: Date) => {
|
||||
if (date < new Date()) {
|
||||
toast.error(t('notes.reminderPastError'))
|
||||
return
|
||||
}
|
||||
setCurrentReminder(date)
|
||||
toast.success(t('notes.reminderSet', { date: date.toLocaleString() }))
|
||||
try {
|
||||
await updateNote(note.id, { reminder: date })
|
||||
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
||||
} catch {
|
||||
toast.error(t('notebook.savingReminder'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveReminder = () => {
|
||||
const handleRemoveReminder = async () => {
|
||||
setCurrentReminder(null)
|
||||
toast.success(t('notes.reminderRemoved'))
|
||||
try {
|
||||
await updateNote(note.id, { reminder: null })
|
||||
toast.success(t('notes.reminderRemoved'))
|
||||
} catch {
|
||||
toast.error(t('notebook.removingReminder'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog'
|
||||
import { Loader2, FileText, RefreshCw } from 'lucide-react'
|
||||
import { Loader2, FileText, RefreshCw, Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { NotebookSummary } from '@/lib/ai/services'
|
||||
@@ -81,10 +81,79 @@ export function NotebookSummaryDialog({
|
||||
setRegenerating(false)
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
if (!summary) return
|
||||
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (!printWindow) return
|
||||
|
||||
const date = new Date(summary.generatedAt).toLocaleString()
|
||||
const labels = summary.stats.labelsUsed.length > 0
|
||||
? `<p><strong>${t('notebook.labels')}</strong> ${summary.stats.labelsUsed.join(', ')}</p>`
|
||||
: ''
|
||||
|
||||
printWindow.document.write(`<!DOCTYPE html>
|
||||
<html lang="${document.documentElement.lang || 'en'}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>${t('notebook.pdfTitle', { name: summary.notebookName })}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: Georgia, 'Times New Roman', serif; font-size: 13pt; line-height: 1.7; color: #111; padding: 2.5cm 3cm; max-width: 800px; margin: 0 auto; }
|
||||
h1 { font-size: 20pt; font-weight: bold; margin-bottom: 0.25em; }
|
||||
.meta { font-size: 10pt; color: #666; margin-bottom: 1.5em; border-bottom: 1px solid #ddd; padding-bottom: 0.75em; }
|
||||
.meta p { margin: 0.2em 0; }
|
||||
.content h1, .content h2, .content h3 { margin-top: 1.2em; margin-bottom: 0.4em; font-family: sans-serif; }
|
||||
.content h2 { font-size: 15pt; }
|
||||
.content h3 { font-size: 13pt; }
|
||||
.content p { margin-bottom: 0.8em; }
|
||||
.content ul, .content ol { margin-left: 1.5em; margin-bottom: 0.8em; }
|
||||
.content li { margin-bottom: 0.3em; }
|
||||
.content strong { font-weight: bold; }
|
||||
.content em { font-style: italic; }
|
||||
.content code { font-family: monospace; background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; }
|
||||
.content blockquote { border-left: 3px solid #ccc; padding-left: 1em; color: #555; margin: 0.8em 0; }
|
||||
@media print {
|
||||
body { padding: 1cm 1.5cm; }
|
||||
@page { margin: 1.5cm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${t('notebook.pdfTitle', { name: summary.notebookName })}</h1>
|
||||
<div class="meta">
|
||||
<p><strong>${t('notebook.pdfNotesLabel')}</strong> ${summary.stats.totalNotes}</p>
|
||||
${labels}
|
||||
<p><strong>${t('notebook.pdfGeneratedOn')}</strong> ${date}</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
${summary.summary
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
.replace(/^[-*] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/^(?!<[hHuUoOblp])(.+)$/gm, '<p>$1</p>')}
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
|
||||
printWindow.document.close()
|
||||
printWindow.focus()
|
||||
setTimeout(() => {
|
||||
printWindow.print()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogContent className="overflow-y-auto" style={{ maxWidth: 'min(48rem, 95vw)', maxHeight: '90vh' }}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{t('notebook.generating')}</DialogTitle>
|
||||
<DialogDescription>{t('notebook.generatingDescription') || 'Please wait...'}</DialogDescription>
|
||||
@@ -106,56 +175,69 @@ export function NotebookSummaryDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between gap-4">
|
||||
<DialogContent
|
||||
className="flex flex-col overflow-hidden p-0"
|
||||
style={{ maxWidth: 'min(64rem, 95vw)', height: '90vh' }}
|
||||
>
|
||||
{/* En-tête fixe */}
|
||||
<div className="flex-shrink-0 px-6 pt-6 pb-4 border-b">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
{t('notebook.summary')}
|
||||
<span className="text-lg font-semibold">{t('notebook.summary')}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
{regenerating
|
||||
? (t('ai.notebookSummary.regenerating') || 'Regenerating...')
|
||||
: (t('ai.notebookSummary.regenerate') || 'Regenerate')}
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExportPDF} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('ai.notebookSummary.exportPDF') || 'Exporter PDF'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRegenerate}
|
||||
disabled={regenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${regenerating ? 'animate-spin' : ''}`} />
|
||||
{regenerating
|
||||
? (t('ai.notebookSummary.regenerating') || 'Regenerating...')
|
||||
: (t('ai.notebookSummary.regenerate') || 'Regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('notebook.summaryDescription', {
|
||||
notebook: summary.notebookName,
|
||||
count: summary.stats.totalNotes,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{summary.stats.totalNotes} {summary.stats.totalNotes === 1 ? 'note' : 'notes'}
|
||||
</span>
|
||||
</div>
|
||||
{summary.stats.labelsUsed.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Labels:</span>
|
||||
<span className="text-sm">{summary.stats.labelsUsed.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{new Date(summary.generatedAt).toLocaleString()}
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Content */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{summary.summary}</ReactMarkdown>
|
||||
{/* Zone scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="flex flex-wrap gap-4 p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">
|
||||
{t('ai.autoLabels.notesCount', { count: summary.stats.totalNotes })}
|
||||
</span>
|
||||
</div>
|
||||
{summary.stats.labelsUsed.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('notebook.labels')}</span>
|
||||
<span className="text-sm">{summary.stats.labelsUsed.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
{new Date(summary.generatedAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenu Markdown */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown>{summary.summary}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -130,7 +130,7 @@ export function NotificationPanel() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-4 w-4 text-primary dark:text-primary-foreground" />
|
||||
<span className="font-semibold text-sm">{t('nav.aiSettings')}</span>
|
||||
<span className="font-semibold text-sm">{t('notification.notifications')}</span>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
|
||||
@@ -166,7 +166,7 @@ export function NotificationPanel() {
|
||||
{request.sharer.name || request.sharer.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
shared "{request.note.title || 'Untitled'}"
|
||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
|
||||
232
keep-notes/components/reminders-page.tsx
Normal file
232
keep-notes/components/reminders-page.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Bell, BellOff, CheckCircle2, Circle, Clock, AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { Note } from '@/lib/types'
|
||||
import { toggleReminderDone } from '@/app/actions/notes'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface RemindersPageProps {
|
||||
notes: Note[]
|
||||
}
|
||||
|
||||
function formatReminderDate(date: Date | string, t: (key: string, params?: Record<string, string | number>) => string, locale = 'fr-FR'): string {
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const noteDay = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
|
||||
const timeStr = d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
if (noteDay.getTime() === today.getTime()) return t('reminders.todayAt', { time: timeStr })
|
||||
if (noteDay.getTime() === tomorrow.getTime()) return t('reminders.tomorrowAt', { time: timeStr })
|
||||
|
||||
return d.toLocaleDateString(locale, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function ReminderCard({ note, onToggleDone, t }: { note: Note; onToggleDone: (id: string, done: boolean) => void; t: (key: string, params?: Record<string, string | number>) => string }) {
|
||||
const now = new Date()
|
||||
const reminderDate = note.reminder ? new Date(note.reminder) : null
|
||||
const isOverdue = reminderDate && reminderDate < now && !note.isReminderDone
|
||||
const isDone = note.isReminderDone
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-start gap-4 rounded-2xl border p-4 transition-all duration-200',
|
||||
isDone
|
||||
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 opacity-60'
|
||||
: isOverdue
|
||||
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'border-slate-200 dark:border-slate-700 bg-white dark:bg-[#1e2128] hover:shadow-md'
|
||||
)}
|
||||
>
|
||||
{/* Done toggle */}
|
||||
<button
|
||||
onClick={() => onToggleDone(note.id, !isDone)}
|
||||
className={cn(
|
||||
'mt-0.5 flex-none transition-colors',
|
||||
isDone
|
||||
? 'text-green-500 hover:text-slate-400'
|
||||
: 'text-slate-300 hover:text-green-500 dark:text-slate-600'
|
||||
)}
|
||||
title={isDone ? t('reminders.markUndone') : t('reminders.markDone')}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{note.title && (
|
||||
<p className={cn('font-semibold text-slate-900 dark:text-white truncate', isDone && 'line-through opacity-60')}>
|
||||
{note.title}
|
||||
</p>
|
||||
)}
|
||||
<p className={cn('text-sm text-slate-600 dark:text-slate-300 line-clamp-2 mt-0.5', isDone && 'line-through opacity-60')}>
|
||||
{note.content}
|
||||
</p>
|
||||
|
||||
{/* Reminder date badge */}
|
||||
{reminderDate && (
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1.5 mt-2 px-2.5 py-1 rounded-full text-xs font-medium',
|
||||
isDone
|
||||
? 'bg-slate-100 dark:bg-slate-700 text-slate-500'
|
||||
: isOverdue
|
||||
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-primary/10 text-primary'
|
||||
)}>
|
||||
{isOverdue && !isDone ? (
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<Clock className="w-3 h-3" />
|
||||
)}
|
||||
{formatReminderDate(reminderDate, t)}
|
||||
{note.reminderRecurrence && (
|
||||
<span className="ml-1 opacity-70">· {note.reminderRecurrence}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ icon: Icon, label, count, color }: { icon: any; label: string; count: number; color: string }) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 mb-3', color)}>
|
||||
<Icon className="w-4 h-4" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider">{label}</h2>
|
||||
<span className="ml-auto text-xs font-medium bg-current/10 px-2 py-0.5 rounded-full opacity-70">{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemindersPage({ notes: initialNotes }: RemindersPageProps) {
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
const [notes, setNotes] = useState(initialNotes)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const upcoming = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) >= now)
|
||||
const overdue = notes.filter(n => !n.isReminderDone && n.reminder && new Date(n.reminder) < now)
|
||||
const done = notes.filter(n => n.isReminderDone)
|
||||
|
||||
const handleToggleDone = (noteId: string, newDone: boolean) => {
|
||||
// Optimistic update
|
||||
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, isReminderDone: newDone } : n))
|
||||
|
||||
startTransition(async () => {
|
||||
await toggleReminderDone(noteId, newDone)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-12 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-primary" />
|
||||
{t('reminders.title') || 'Rappels'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center text-slate-500 dark:text-slate-400">
|
||||
<div className="bg-slate-100 dark:bg-slate-800 p-6 rounded-full mb-4">
|
||||
<BellOff className="w-12 h-12 text-slate-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2 text-slate-700 dark:text-slate-300">
|
||||
{t('reminders.empty') || 'Aucun rappel'}
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm opacity-80">
|
||||
{t('reminders.emptyDescription') || 'Ajoutez un rappel à une note pour le retrouver ici.'}
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-3xl">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-primary" />
|
||||
{t('reminders.title') || 'Rappels'}
|
||||
</h1>
|
||||
{isPending && (
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* En retard */}
|
||||
{overdue.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={AlertCircle}
|
||||
label={t('reminders.overdue') || 'En retard'}
|
||||
count={overdue.length}
|
||||
color="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{overdue.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* À venir */}
|
||||
{upcoming.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={Clock}
|
||||
label={t('reminders.upcoming') || 'À venir'}
|
||||
count={upcoming.length}
|
||||
color="text-primary"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{upcoming.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Terminés */}
|
||||
{done.length > 0 && (
|
||||
<section>
|
||||
<SectionTitle
|
||||
icon={CheckCircle2}
|
||||
label={t('reminders.done') || 'Terminés'}
|
||||
count={done.length}
|
||||
color="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{done.map(note => (
|
||||
<ReminderCard key={note.id} note={note} onToggleDone={handleToggleDone} t={t} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export interface Section {
|
||||
id: string
|
||||
@@ -23,12 +24,15 @@ interface SettingsSearchProps {
|
||||
export function SettingsSearch({
|
||||
sections,
|
||||
onFilter,
|
||||
placeholder = 'Search settings...',
|
||||
placeholder,
|
||||
className
|
||||
}: SettingsSearchProps) {
|
||||
const { t } = useLanguage()
|
||||
const [query, setQuery] = useState('')
|
||||
const [filteredSections, setFilteredSections] = useState<Section[]>(sections)
|
||||
|
||||
const searchPlaceholder = placeholder || t('settings.searchNoResults') || 'Search settings...'
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setFilteredSections(sections)
|
||||
@@ -77,7 +81,7 @@ export function SettingsSearch({
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
placeholder={searchPlaceholder}
|
||||
className="pl-10"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -86,14 +90,14 @@ export function SettingsSearch({
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
|
||||
aria-label="Clear search"
|
||||
aria-label={t('search.placeholder')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{isEmptySearch && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 p-2 bg-white rounded-lg shadow-lg border z-50">
|
||||
<p className="text-sm text-gray-600">No settings found</p>
|
||||
<p className="text-sm text-gray-600">{t('settings.searchNoResults')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user