chore: clean up repo for public release

- Remove BMAD framework, IDE configs, dev screenshots, test files,
  internal docs, and backup files
- Rename keep-notes/ to memento-note/
- Update all references from keep-notes to memento-note
- Add Apache 2.0 license with Commons Clause (non-commercial restriction)
- Add clean .gitignore and .env.docker.example
This commit is contained in:
Sepehr Ramezani
2026-04-20 22:48:06 +02:00
parent 402e88b788
commit e4d4e23dc7
3981 changed files with 407 additions and 530622 deletions

View File

@@ -0,0 +1,19 @@
import { cn } from '@/lib/utils'
export interface AdminContentAreaProps {
children: React.ReactNode
className?: string
}
export function AdminContentArea({ children, className }: AdminContentAreaProps) {
return (
<main
className={cn(
'flex-1 bg-gray-50 dark:bg-zinc-950 p-6',
className
)}
>
{children}
</main>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { Card } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
export interface MetricItem {
title: string
value: string | number
trend?: {
value: number
isPositive: boolean
}
icon?: React.ReactNode
}
export interface AdminMetricsProps {
metrics: MetricItem[]
className?: string
}
export function AdminMetrics({ metrics, className }: AdminMetricsProps) {
const { t } = useLanguage()
return (
<div
className={cn(
'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4',
className
)}
>
{metrics.map((metric, index) => (
<Card
key={index}
className="p-6 bg-white dark:bg-zinc-900 border-gray-200 dark:border-gray-800"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">
{metric.title}
</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{metric.value}
</p>
{metric.trend && (
<div className="flex items-center gap-1 mt-2">
<span
className={cn(
'text-xs font-medium',
metric.trend.isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
)}
>
{metric.trend.isPositive ? '↑' : '↓'} {Math.abs(metric.trend.value)}%
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('admin.metrics.vsLastPeriod')}
</span>
</div>
)}
</div>
{metric.icon && (
<div className="p-2 bg-gray-100 dark:bg-zinc-800 rounded-lg">
{metric.icon}
</div>
)}
</div>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,17 @@
'use client'
import { useLanguage } from '@/lib/i18n'
export function AdminPageHeader() {
const { t } = useLanguage()
return (
<h1 className="text-3xl font-bold">{t('nav.userManagement')}</h1>
)
}
export function SettingsButton() {
const { t } = useLanguage()
return t('settings.title')
}

View File

@@ -0,0 +1,77 @@
'use client'
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 {
titleKey: string
href: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{
titleKey: 'admin.sidebar.dashboard',
href: '/admin',
icon: <LayoutDashboard className="h-5 w-5" />,
},
{
titleKey: 'admin.sidebar.users',
href: '/admin/users',
icon: <Users className="h-5 w-5" />,
},
{
titleKey: 'admin.sidebar.aiManagement',
href: '/admin/ai',
icon: <Brain className="h-5 w-5" />,
},
{
titleKey: 'admin.sidebar.settings',
href: '/admin/settings',
icon: <Settings className="h-5 w-5" />,
},
]
export function AdminSidebar({ className }: AdminSidebarProps) {
const pathname = usePathname()
const { t } = useLanguage()
return (
<aside
className={cn(
'w-64 bg-white dark:bg-zinc-900 border-r border-gray-200 dark:border-gray-800 p-4',
className
)}
>
<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}
href={item.href}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
'hover:bg-gray-100 dark:hover:bg-zinc-800',
isActive
? 'bg-gray-100 dark:bg-zinc-800 text-gray-900 dark:text-white font-semibold'
: 'text-gray-600 dark:text-gray-400'
)}
>
{item.icon}
<span>{t(item.titleKey)}</span>
</Link>
)
})}
</nav>
</aside>
)
}

View File

@@ -0,0 +1,244 @@
'use client'
/**
* Agent Card Component
* Displays a single agent with status, actions, and metadata.
*/
import { useState, useEffect } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import {
Play,
Trash2,
Loader2,
ToggleLeft,
ToggleRight,
Globe,
Search,
Eye,
Settings,
CheckCircle2,
XCircle,
Clock,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { getNotebookIcon } from '@/lib/notebook-icon'
// --- Types ---
interface AgentCardProps {
agent: {
id: string
name: string
description?: string | null
type?: string | null
isEnabled: boolean
frequency: string
lastRun: string | Date | null
createdAt: string | Date
updatedAt: string | Date
_count: { actions: number }
actions: { id: string; status: string; createdAt: string | Date }[]
notebook?: { id: string; name: string; icon?: string | null } | null
}
onEdit: (id: string) => void
onRefresh: () => void
onToggle: (id: string, isEnabled: boolean) => void
}
// --- Config ---
const typeConfig: Record<string, { icon: typeof Globe; color: string; bgColor: string }> = {
scraper: { icon: Globe, color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800' },
researcher: { icon: Search, color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800' },
monitor: { icon: Eye, color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800' },
custom: { icon: Settings, color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800' },
}
const frequencyKeys: Record<string, string> = {
manual: 'agents.frequencies.manual',
hourly: 'agents.frequencies.hourly',
daily: 'agents.frequencies.daily',
weekly: 'agents.frequencies.weekly',
monthly: 'agents.frequencies.monthly',
}
const statusKeys: Record<string, string> = {
success: 'agents.status.success',
failure: 'agents.status.failure',
running: 'agents.status.running',
pending: 'agents.status.pending',
}
// --- Component ---
export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps) {
const { t, language } = useLanguage()
const [isRunning, setIsRunning] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const [mounted, setMounted] = useState(false)
// Prevent hydration mismatch for date formatting
useEffect(() => { setMounted(true) }, [])
const config = typeConfig[agent.type || 'scraper'] || typeConfig.custom
const Icon = config.icon
const lastAction = agent.actions[0]
const dateLocale = language === 'fr' ? fr : enUS
const isNew = new Date(agent.createdAt).getTime() === new Date(agent.updatedAt).getTime()
const handleRun = async () => {
setIsRunning(true)
try {
const { runAgent } = await import('@/app/actions/agent-actions')
const result = await runAgent(agent.id)
if (result.success) {
toast.success(t('agents.toasts.runSuccess', { name: agent.name }))
} else {
toast.error(t('agents.toasts.runError', { error: result.error || t('agents.toasts.runFailed') }))
}
} catch {
toast.error(t('agents.toasts.runGenericError'))
} finally {
setIsRunning(false)
onRefresh()
}
}
const handleDelete = async () => {
if (!confirm(t('agents.actions.deleteConfirm', { name: agent.name }))) return
setIsDeleting(true)
try {
const { deleteAgent } = await import('@/app/actions/agent-actions')
await deleteAgent(agent.id)
toast.success(t('agents.toasts.deleted', { name: agent.name }))
} catch {
toast.error(t('agents.toasts.deleteError'))
} finally {
setIsDeleting(false)
onRefresh()
}
}
const handleToggle = async () => {
const newEnabled = !agent.isEnabled
setIsToggling(true)
onToggle(agent.id, newEnabled)
try {
const { toggleAgent } = await import('@/app/actions/agent-actions')
await toggleAgent(agent.id, newEnabled)
toast.success(newEnabled ? t('agents.actions.toggleOn') : t('agents.actions.toggleOff'))
} catch {
onToggle(agent.id, !newEnabled)
toast.error(t('agents.toasts.toggleError'))
} finally {
setIsToggling(false)
}
}
return (
<div className={`
bg-card rounded-xl border-2 transition-all overflow-hidden
${agent.isEnabled ? 'border-border hover:border-primary/30 hover:shadow-md' : 'border-border/50 opacity-60'}
`}>
<div className={`h-1 ${agent.isEnabled ? 'bg-primary' : 'bg-muted'}`} />
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`p-2 rounded-lg border ${config.bgColor}`}>
<Icon className={`w-4 h-4 ${config.color}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-card-foreground truncate">{agent.name}</h3>
{mounted && isNew && (
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded">
{t('agents.newBadge')}
</span>
)}
</div>
<span className={`text-xs font-medium ${config.color}`}>
{t(`agents.types.${agent.type || 'custom'}`)}
</span>
</div>
</div>
<button onClick={handleToggle} disabled={isToggling} className="flex-shrink-0 ml-2 disabled:opacity-50 disabled:cursor-not-allowed">
{agent.isEnabled ? (
<ToggleRight className="w-6 h-6 text-primary" />
) : (
<ToggleLeft className="w-6 h-6 text-muted-foreground/40" />
)}
</button>
</div>
{agent.description && (
<p className="text-xs text-muted-foreground mb-3 line-clamp-2">{agent.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{t(frequencyKeys[agent.frequency] || 'agents.frequencies.manual')}
</span>
{agent.notebook && (
<span className="flex items-center gap-1">
{(() => {
const Icon = getNotebookIcon(agent.notebook.icon)
return <Icon className="w-3 h-3" />
})()} {agent.notebook.name}
</span>
)}
<span>{t('agents.metadata.executions', { count: agent._count.actions })}</span>
</div>
{lastAction && (
<div className={`
flex items-center gap-1.5 text-xs px-2 py-1 rounded-md mb-3
${lastAction.status === 'success' ? 'bg-green-50 dark:bg-green-950 text-green-600 dark:text-green-400' : ''}
${lastAction.status === 'failure' ? 'bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400' : ''}
${lastAction.status === 'running' ? 'bg-blue-50 dark:bg-blue-950 text-blue-600 dark:text-blue-400' : ''}
`}>
{lastAction.status === 'success' && <CheckCircle2 className="w-3 h-3" />}
{lastAction.status === 'failure' && <XCircle className="w-3 h-3" />}
{lastAction.status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{t(statusKeys[lastAction.status] || lastAction.status)}
{' - '}
{mounted
? formatDistanceToNow(new Date(lastAction.createdAt), { addSuffix: true, locale: dateLocale })
: new Date(lastAction.createdAt).toISOString().split('T')[0]}
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={() => onEdit(agent.id)}
className="flex-1 px-3 py-1.5 text-xs font-medium text-primary bg-primary/5 rounded-lg hover:bg-primary/10 transition-colors"
>
{t('agents.actions.edit')}
</button>
<button
onClick={handleRun}
disabled={isRunning || !agent.isEnabled}
className="p-1.5 text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-950 rounded-lg hover:bg-green-100 dark:hover:bg-green-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={t('agents.actions.run')}
>
{isRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="p-1.5 text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-950 rounded-lg hover:bg-red-100 dark:hover:bg-red-900 transition-colors disabled:opacity-50"
title={t('agents.actions.delete')}
>
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,509 @@
'use client'
/**
* Agent Form Component
* Simplified form for creating and editing agents.
* Novice-friendly: hides system prompt and tools behind "Advanced mode".
*/
import { useState, useMemo, useRef } from 'react'
import { X, Plus, Trash2, Globe, FileSearch, FilePlus, FileText, ExternalLink, Brain, ChevronDown, ChevronUp, HelpCircle, Mail, ImageIcon } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
// --- Types ---
type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom'
/** Small "?" tooltip shown next to form labels */
function FieldHelp({ tooltip }: { tooltip: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="inline-flex items-center ml-1 text-muted-foreground/40 hover:text-muted-foreground transition-colors">
<HelpCircle className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-balance">
{tooltip}
</TooltipContent>
</Tooltip>
)
}
interface AgentFormProps {
agent?: {
id: string
name: string
description?: string | null
type?: string | null
role: string
sourceUrls?: string | null
sourceNotebookId?: string | null
targetNotebookId?: string | null
frequency: string
tools?: string | null
maxSteps?: number
notifyEmail?: boolean
includeImages?: boolean
} | null
notebooks: { id: string; name: string; icon?: string | null }[]
onSave: (data: FormData) => Promise<void>
onCancel: () => void
}
// --- Tool presets per type ---
const TOOL_PRESETS: Record<string, string[]> = {
scraper: ['web_scrape', 'note_create', 'memory_search'],
researcher: ['web_search', 'web_scrape', 'note_search', 'note_create', 'memory_search'],
monitor: ['note_search', 'note_read', 'note_create', 'memory_search'],
custom: ['memory_search'],
}
// --- Shared class strings ---
const labelCls = 'block text-sm font-medium text-foreground mb-1.5'
const labelCls2 = 'block text-sm font-medium text-foreground mb-2'
const inputCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
const selectCls = 'w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-card text-foreground'
const toggleOffBorder = 'border-border hover:border-primary/30'
const toggleOffIcon = 'text-muted-foreground'
const toggleOffLabel = 'text-sm font-medium text-foreground'
const toggleOffHint = 'text-xs text-muted-foreground'
// --- Component ---
export function AgentForm({ agent, notebooks, onSave, onCancel }: AgentFormProps) {
const { t } = useLanguage()
const [name, setName] = useState(agent?.name || '')
const [description, setDescription] = useState(agent?.description || '')
const [type, setType] = useState<AgentType>((agent?.type as AgentType) || 'scraper')
const [role, setRole] = useState(agent?.role || '')
const [urls, setUrls] = useState<string[]>(() => {
if (agent?.sourceUrls) {
try { return JSON.parse(agent.sourceUrls) } catch { return [''] }
}
return ['']
})
const [sourceNotebookId, setSourceNotebookId] = useState(agent?.sourceNotebookId || '')
const [targetNotebookId, setTargetNotebookId] = useState(agent?.targetNotebookId || '')
const [frequency, setFrequency] = useState(agent?.frequency || 'manual')
const [selectedTools, setSelectedTools] = useState<string[]>(() => {
if (agent?.tools) {
try {
const parsed = JSON.parse(agent.tools)
if (parsed.length > 0) return parsed
} catch { /* fall through to presets */ }
}
// New agent or old agent with empty tools: use preset defaults
const defaultType = (agent?.type as AgentType) || 'scraper'
return TOOL_PRESETS[defaultType] || []
})
const [maxSteps, setMaxSteps] = useState(agent?.maxSteps || 10)
const [notifyEmail, setNotifyEmail] = useState(agent?.notifyEmail || false)
const [includeImages, setIncludeImages] = useState(agent?.includeImages || false)
const [isSaving, setIsSaving] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(() => {
// Auto-open advanced if editing an agent with custom tools or custom prompt
if (agent?.tools) {
try {
const tools = JSON.parse(agent.tools)
if (tools.length > 0) return true
} catch { /* ignore */ }
}
// Also open if agent has a custom role (instructions)
if (agent?.role && agent.role.trim().length > 0) return true
return false
})
// Tool definitions
const availableTools = useMemo(() => [
{ id: 'web_search', icon: Globe, labelKey: 'agents.tools.webSearch', external: true },
{ id: 'web_scrape', icon: ExternalLink, labelKey: 'agents.tools.webScrape', external: true },
{ id: 'note_search', icon: FileSearch, labelKey: 'agents.tools.noteSearch', external: false },
{ id: 'note_read', icon: FileText, labelKey: 'agents.tools.noteRead', external: false },
{ id: 'note_create', icon: FilePlus, labelKey: 'agents.tools.noteCreate', external: false },
{ id: 'url_fetch', icon: ExternalLink, labelKey: 'agents.tools.urlFetch', external: false },
{ id: 'memory_search', icon: Brain, labelKey: 'agents.tools.memorySearch', external: false },
], [])
// Track previous type to detect user-initiated type changes
const prevTypeRef = useRef(type)
// When user explicitly changes type (not on mount), reset tools to presets
if (prevTypeRef.current !== type) {
prevTypeRef.current = type
// This is a user-initiated type change, not a mount
// We queue the state update to happen after render
setSelectedTools(TOOL_PRESETS[type] || [])
setRole('')
}
const addUrl = () => setUrls([...urls, ''])
const removeUrl = (index: number) => setUrls(urls.filter((_, i) => i !== index))
const updateUrl = (index: number, value: string) => {
const newUrls = [...urls]
newUrls[index] = value
setUrls(newUrls)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toast.error(t('agents.form.nameRequired'))
return
}
setIsSaving(true)
try {
const formData = new FormData()
formData.set('name', name.trim())
formData.set('description', description.trim())
formData.set('type', type)
formData.set('role', role || t(`agents.defaultRoles.${type}`))
formData.set('frequency', frequency)
formData.set('targetNotebookId', targetNotebookId)
if (type === 'monitor') {
formData.set('sourceNotebookId', sourceNotebookId)
}
const validUrls = urls.filter(u => u.trim())
if (validUrls.length > 0) {
formData.set('sourceUrls', JSON.stringify(validUrls))
}
formData.set('tools', JSON.stringify(selectedTools))
formData.set('maxSteps', String(maxSteps))
formData.set('notifyEmail', String(notifyEmail))
formData.set('includeImages', String(includeImages))
await onSave(formData)
} catch {
toast.error(t('agents.toasts.saveError'))
} finally {
setIsSaving(false)
}
}
const showSourceNotebook = type === 'monitor'
const agentTypes: { value: AgentType; labelKey: string; descKey: string }[] = [
{ value: 'researcher', labelKey: 'agents.types.researcher', descKey: 'agents.typeDescriptions.researcher' },
{ value: 'scraper', labelKey: 'agents.types.scraper', descKey: 'agents.typeDescriptions.scraper' },
{ value: 'monitor', labelKey: 'agents.types.monitor', descKey: 'agents.typeDescriptions.monitor' },
{ value: 'custom', labelKey: 'agents.types.custom', descKey: 'agents.typeDescriptions.custom' },
]
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-2xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header — editable agent name */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="text-lg font-semibold text-card-foreground bg-transparent border-none outline-none focus:ring-0 p-0 flex-1 placeholder:text-muted-foreground/40"
placeholder={t('agents.form.namePlaceholder')}
required
/>
<button onClick={onCancel} className="p-1 rounded-md hover:bg-accent ml-3">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Agent Type */}
<div>
<label className={labelCls2}>{t('agents.form.agentType')}<FieldHelp tooltip={t('agents.help.tooltips.agentType')} /></label>
<div className="grid grid-cols-2 gap-2">
{agentTypes.map(at => (
<button
key={at.value}
type="button"
onClick={() => setType(at.value)}
className={`
text-left px-3 py-2.5 rounded-lg border-2 transition-all text-sm
${type === at.value
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`}
`}
>
<div className="font-medium">{t(at.labelKey)}</div>
<div className="text-xs text-muted-foreground mt-0.5">{t(at.descKey)}</div>
</button>
))}
</div>
</div>
{/* Research Topic (researcher only) — replaces Description for this type */}
{type === 'researcher' && (
<div>
<label className={labelCls}>{t('agents.form.researchTopic')}<FieldHelp tooltip={t('agents.help.tooltips.researchTopic')} /></label>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
className={inputCls}
placeholder={t('agents.form.researchTopicPlaceholder')}
/>
</div>
)}
{/* Description (for non-researcher types) */}
{type !== 'researcher' && (
<div>
<label className={labelCls}>{t('agents.form.description')}<FieldHelp tooltip={t('agents.help.tooltips.description')} /></label>
<input
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
className={inputCls}
placeholder={t('agents.form.descriptionPlaceholder')}
/>
</div>
)}
{/* URLs (scraper and custom only — researcher uses search, not URLs) */}
{(type === 'scraper' || type === 'custom') && (
<div>
<label className={labelCls}>
{t('agents.form.urlsLabel')}<FieldHelp tooltip={t('agents.help.tooltips.urls')} />
</label>
<div className="space-y-2">
{urls.map((url, i) => (
<div key={i} className="flex gap-2">
<input
type="url"
value={url}
onChange={e => updateUrl(i, e.target.value)}
className={inputCls}
placeholder="https://example.com"
/>
{urls.length > 1 && (
<button
type="button"
onClick={() => removeUrl(i)}
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
<button
type="button"
onClick={addUrl}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
>
<Plus className="w-3.5 h-3.5" />
{t('agents.form.addUrl')}
</button>
</div>
</div>
)}
{/* Source Notebook (monitor only) */}
{showSourceNotebook && (
<div>
<label className={labelCls}>{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
<select
value={sourceNotebookId}
onChange={e => setSourceNotebookId(e.target.value)}
className={selectCls}
>
<option value="">{t('agents.form.selectNotebook')}</option>
{notebooks.map(nb => (
<option key={nb.id} value={nb.id}>
{nb.name}
</option>
))}
</select>
</div>
)}
{/* Target Notebook */}
<div>
<label className={labelCls}>{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
<select
value={targetNotebookId}
onChange={e => setTargetNotebookId(e.target.value)}
className={selectCls}
>
<option value="">{t('agents.form.inbox')}</option>
{notebooks.map(nb => (
<option key={nb.id} value={nb.id}>
{nb.name}
</option>
))}
</select>
</div>
{/* Frequency */}
<div>
<label className={labelCls}>{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></label>
<select
value={frequency}
onChange={e => setFrequency(e.target.value)}
className={selectCls}
>
<option value="manual">{t('agents.frequencies.manual')}</option>
<option value="hourly">{t('agents.frequencies.hourly')}</option>
<option value="daily">{t('agents.frequencies.daily')}</option>
<option value="weekly">{t('agents.frequencies.weekly')}</option>
<option value="monthly">{t('agents.frequencies.monthly')}</option>
</select>
</div>
{/* Email Notification */}
<div
onClick={() => setNotifyEmail(!notifyEmail)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
notifyEmail
? 'border-primary bg-primary/5'
: toggleOffBorder
}`}
>
<Mail className={`w-4 h-4 flex-shrink-0 ${notifyEmail ? 'text-primary' : toggleOffIcon}`} />
<div className="flex-1 min-w-0">
<div className={toggleOffLabel}>{t('agents.form.notifyEmail')}</div>
<div className={toggleOffHint}>{t('agents.form.notifyEmailHint')}</div>
</div>
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-muted'}`}>
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${notifyEmail ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
{/* Include Images */}
<div
onClick={() => setIncludeImages(!includeImages)}
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
includeImages
? 'border-primary bg-primary/5'
: toggleOffBorder
}`}
>
<ImageIcon className={`w-4 h-4 flex-shrink-0 ${includeImages ? 'text-primary' : toggleOffIcon}`} />
<div className="flex-1 min-w-0">
<div className={toggleOffLabel}>{t('agents.form.includeImages')}</div>
<div className={toggleOffHint}>{t('agents.form.includeImagesHint')}</div>
</div>
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-muted'}`}>
<div className={`w-4 h-4 bg-card rounded-full shadow-sm transition-transform mt-0.5 ${includeImages ? 'translate-x-4.5 ml-0.5' : 'ml-0.5'}`} />
</div>
</div>
{/* Advanced mode toggle */}
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground font-medium w-full pt-2 border-t border-border"
>
{showAdvanced ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
{t('agents.form.advancedMode')}
</button>
{/* Advanced: System Prompt */}
{showAdvanced && (
<>
<div>
<label className="block text-sm font-medium text-foreground mb-1">
{t('agents.form.instructions')}
<FieldHelp tooltip={t('agents.help.tooltips.instructions')} />
<span className="text-xs text-muted-foreground font-normal ml-1">({t('agents.form.instructionsHint')})</span>
</label>
<textarea
value={role}
onChange={e => setRole(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-input rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-y min-h-[80px] bg-card text-foreground"
placeholder={t('agents.form.instructionsPlaceholder')}
/>
</div>
{/* Advanced: Tools */}
<div>
<label className="block text-sm font-medium text-foreground mb-2">{t('agents.tools.title')}<FieldHelp tooltip={t('agents.help.tooltips.tools')} /></label>
<div className="grid grid-cols-2 gap-2">
{availableTools.map(at => {
const Icon = at.icon
const isSelected = selectedTools.includes(at.id)
return (
<button
key={at.id}
type="button"
onClick={() => {
setSelectedTools(prev =>
isSelected ? prev.filter(t => t !== at.id) : [...prev, at.id]
)
}}
className={`
flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-all text-left
${isSelected
? 'border-primary bg-primary/5 text-primary font-medium'
: `${toggleOffBorder} text-muted-foreground`}
`}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{t(at.labelKey)}</span>
{at.external && !isSelected && (
<span className="ml-auto text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
)}
</button>
)
})}
</div>
{selectedTools.length > 0 && (
<p className="text-xs text-muted-foreground mt-1.5">
{t('agents.tools.selected', { count: selectedTools.length })}
</p>
)}
</div>
{/* Advanced: Max Steps */}
{selectedTools.length > 0 && (
<div>
<label className="block text-sm font-medium text-foreground mb-1.5">
{t('agents.tools.maxSteps')}<FieldHelp tooltip={t('agents.help.tooltips.maxSteps')} />
<span className="text-muted-foreground font-normal ml-1">({maxSteps})</span>
</label>
<input
type="range"
min={3}
max={25}
value={maxSteps}
onChange={e => setMaxSteps(Number(e.target.value))}
className="w-full accent-primary"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>3</span>
<span>25</span>
</div>
</div>
)}
</>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-muted-foreground bg-muted rounded-lg hover:bg-accent transition-colors"
>
{t('agents.form.cancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isSaving ? t('agents.form.saving') : agent ? t('agents.form.save') : t('agents.form.create')}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
/**
* Agent Help Modal
* Rich contextual help guide for the Agents page.
* Collapsible sections with Markdown content inside each.
*/
import { X, LifeBuoy } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useLanguage } from '@/lib/i18n'
interface AgentHelpProps {
onClose: () => void
}
const SECTIONS = [
{ key: 'whatIsAgent', defaultOpen: true },
{ key: 'howToUse', defaultOpen: false },
{ key: 'types', defaultOpen: false },
{ key: 'advanced', defaultOpen: false },
{ key: 'tools', defaultOpen: false },
{ key: 'frequency', defaultOpen: false },
{ key: 'targetNotebook', defaultOpen: false },
{ key: 'templates', defaultOpen: false },
{ key: 'tips', defaultOpen: false },
] as const
export function AgentHelp({ onClose }: AgentHelpProps) {
const { t } = useLanguage()
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-center gap-2.5">
<LifeBuoy className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t('agents.help.title')}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content — collapsible sections */}
<div className="flex-1 overflow-y-auto px-6 py-2">
{SECTIONS.map(section => (
<details
key={section.key}
open={section.defaultOpen}
className="group border-b border-slate-100 dark:border-slate-800 last:border-b-0"
>
<summary className="flex items-center gap-2 cursor-pointer py-3 font-medium text-slate-800 dark:text-slate-200 select-none hover:text-primary transition-colors text-sm">
<span className="text-primary text-xs transition-transform group-open:rotate-90">&#9656;</span>
{t(`agents.help.${section.key}`)}
</summary>
<div className="pb-4 pl-5 prose prose-slate dark:prose-invert prose-sm max-w-none
prose-headings:font-semibold prose-headings:text-slate-800 dark:prose-headings:text-slate-200
prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1
prose-p:leading-relaxed prose-p:text-slate-600 dark:prose-p:text-slate-400 prose-p:my-1.5
prose-li:text-slate-600 dark:prose-li:text-slate-400 prose-li:my-0.5
prose-strong:text-slate-700 dark:prose-strong:text-slate-300
prose-code:text-primary prose-code:bg-primary/5 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:text-xs prose-code:before:content-none prose-code:after:content-none
prose-ul:my-2 prose-ol:my-2
prose-hr:border-slate-200 dark:prose-hr:border-slate-700
prose-table:text-xs
prose-th:text-left prose-th:font-medium prose-th:text-slate-700 dark:prose-th:text-slate-300 prose-th:py-1 prose-th:pr-3
prose-td:text-slate-600 dark:prose-td:text-slate-400 prose-td:py-1 prose-td:pr-3
prose-blockquote:border-primary/30 prose-blockquote:text-slate-500 dark:prose-blockquote:text-slate-400
">
<Markdown remarkPlugins={[remarkGfm]}>
{t(`agents.help.${section.key}Content`)}
</Markdown>
</div>
</details>
))}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 shrink-0">
<button
onClick={onClose}
className="w-full px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary/90 transition-colors"
>
{t('agents.help.close')}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
/**
* Agent Run Log
* Shows execution history for an agent.
*/
import { useState, useEffect } from 'react'
import { X, CheckCircle2, XCircle, Loader2, Clock, ChevronDown, Wrench } from 'lucide-react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { useLanguage } from '@/lib/i18n'
interface AgentRunLogProps {
agentId: string
agentName: string
onClose: () => void
}
interface Action {
id: string
status: string
result?: string | null
log?: string | null
input?: string | null
toolLog?: string | null
tokensUsed?: number | null
createdAt: string | Date
}
interface ToolLogStep {
step: number
text?: string
toolCalls?: Array<{ toolName: string; args: any }>
toolResults?: Array<{ toolName: string; preview?: string }>
}
const statusKeys: Record<string, string> = {
success: 'agents.status.success',
failure: 'agents.status.failure',
running: 'agents.status.running',
pending: 'agents.status.pending',
}
export function AgentRunLog({ agentId, agentName, onClose }: AgentRunLogProps) {
const { t, language } = useLanguage()
const [actions, setActions] = useState<Action[]>([])
const [loading, setLoading] = useState(true)
const dateLocale = language === 'fr' ? fr : enUS
useEffect(() => {
async function load() {
try {
const { getAgentActions } = await import('@/app/actions/agent-actions')
const data = await getAgentActions(agentId)
setActions(data)
} catch {
// Silent fail
} finally {
setLoading(false)
}
}
load()
}, [agentId])
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4">
<div className="bg-card rounded-2xl shadow-xl max-w-md w-full max-h-[70vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div>
<h3 className="font-semibold text-card-foreground">{t('agents.runLog.title')}</h3>
<p className="text-xs text-muted-foreground">{agentName}</p>
</div>
<button onClick={onClose} className="p-1 rounded-md hover:bg-accent">
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && actions.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-8">
{t('agents.runLog.noHistory')}
</p>
)}
{actions.map(action => {
let toolSteps: ToolLogStep[] = []
try {
toolSteps = action.toolLog ? JSON.parse(action.toolLog) : []
} catch {}
return (
<div
key={action.id}
className={`
p-3 rounded-lg border
${action.status === 'success' ? 'bg-green-50/50 dark:bg-green-950/50 border-green-100 dark:border-green-900' : ''}
${action.status === 'failure' ? 'bg-red-50/50 dark:bg-red-950/50 border-red-100 dark:border-red-900' : ''}
${action.status === 'running' ? 'bg-blue-50/50 dark:bg-blue-950/50 border-blue-100 dark:border-blue-900' : ''}
${action.status === 'pending' ? 'bg-muted border-border' : ''}
`}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{action.status === 'success' && <CheckCircle2 className="w-4 h-4 text-green-500" />}
{action.status === 'failure' && <XCircle className="w-4 h-4 text-red-500" />}
{action.status === 'running' && <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />}
{action.status === 'pending' && <Clock className="w-4 h-4 text-muted-foreground" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{t(statusKeys[action.status] || action.status)}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>
{action.log && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{action.log}</p>
)}
</div>
</div>
{/* Tool trace */}
{toolSteps.length > 0 && (
<details className="mt-2">
<summary className="flex items-center gap-1.5 text-xs text-primary cursor-pointer hover:text-primary/80 font-medium">
<Wrench className="w-3 h-3" />
{t('agents.runLog.toolTrace', { count: toolSteps.length })}
<ChevronDown className="w-3 h-3" />
</summary>
<div className="mt-2 space-y-2 pl-2">
{toolSteps.map((step, i) => (
<div key={i} className="text-xs border-l-2 border-primary/30 pl-2 py-1">
<span className="font-medium text-muted-foreground">{t('agents.runLog.step', { num: step.step })}</span>
{step.toolCalls && step.toolCalls.length > 0 && (
<div className="mt-1 space-y-1">
{step.toolCalls.map((tc, j) => (
<div key={j} className="bg-muted rounded px-2 py-1">
<span className="font-mono text-primary">{tc.toolName}</span>
<span className="text-muted-foreground ml-1">
{JSON.stringify(tc.args).substring(0, 80)}
</span>
</div>
))}
</div>
)}
</div>
))}
</div>
</details>
)}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,150 @@
'use client'
/**
* Agent Templates Gallery
* Pre-built agent configurations that users can install in one click.
*/
import { useState } from 'react'
import {
Globe,
Search,
Eye,
Settings,
Plus,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface AgentTemplatesProps {
onInstalled: () => void
existingAgentNames: string[]
}
const templateConfig = [
{ id: 'veilleAI', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
'https://www.theverge.com/rss/ai-artificial-intelligence/index.xml',
'https://techcrunch.com/category/artificial-intelligence/feed/',
'https://feeds.arstechnica.com/arstechnica/technology-lab',
'https://www.technologyreview.com/feed/',
'https://www.wired.com/feed/',
'https://korben.info/feed',
], frequency: 'weekly' },
{ id: 'veilleTech', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
'https://news.ycombinator.com/rss',
'https://dev.to/feed',
'https://www.producthunt.com/feed',
], frequency: 'daily' },
{ id: 'veilleDev', type: 'scraper', roleKey: 'agents.defaultRoles.scraper', urls: [
'https://dev.to/feed/tag/javascript',
'https://dev.to/feed/tag/typescript',
'https://dev.to/feed/tag/react',
], frequency: 'weekly' },
{ id: 'surveillant', type: 'monitor', roleKey: 'agents.defaultRoles.monitor', urls: [], frequency: 'weekly' },
{ id: 'chercheur', type: 'researcher', roleKey: 'agents.defaultRoles.researcher', urls: [], frequency: 'manual' },
] as const
const typeIcons: Record<string, typeof Globe> = {
scraper: Globe,
researcher: Search,
monitor: Eye,
custom: Settings,
}
const typeColors: Record<string, string> = {
scraper: 'text-blue-600 bg-blue-50',
researcher: 'text-purple-600 bg-purple-50',
monitor: 'text-amber-600 bg-amber-50',
custom: 'text-green-600 bg-green-50',
}
export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplatesProps) {
const { t } = useLanguage()
const [installingId, setInstallingId] = useState<string | null>(null)
const handleInstall = async (tpl: typeof templateConfig[number]) => {
setInstallingId(tpl.id)
try {
const { createAgent } = await import('@/app/actions/agent-actions')
const nameKey = `agents.templates.${tpl.id}.name` as const
const descKey = `agents.templates.${tpl.id}.description` as const
const baseName = t(nameKey)
let resolvedName = baseName
if (existingAgentNames.includes(baseName)) {
let n = 2
while (existingAgentNames.includes(`${baseName} ${n}`)) n++
resolvedName = `${baseName} ${n}`
}
await createAgent({
name: resolvedName,
description: t(descKey),
type: tpl.type,
role: t(tpl.roleKey),
sourceUrls: tpl.urls.length > 0 ? [...tpl.urls] : undefined,
frequency: tpl.frequency,
tools: tpl.type === 'scraper'
? ['web_scrape', 'note_create']
: tpl.type === 'researcher'
? ['web_search', 'web_scrape', 'note_search', 'note_create']
: tpl.type === 'monitor'
? ['note_search', 'note_read', 'note_create']
: [],
})
toast.success(t('agents.toasts.installSuccess', { name: resolvedName }))
onInstalled()
} catch {
toast.error(t('agents.toasts.installError'))
} finally {
setInstallingId(null)
}
}
return (
<div>
<h3 className="text-sm font-semibold text-slate-500 uppercase tracking-wider mb-3">
{t('agents.templates.title')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{templateConfig.map(tpl => {
const Icon = typeIcons[tpl.type] || Settings
const isInstalling = installingId === tpl.id
const nameKey = `agents.templates.${tpl.id}.name`
const descKey = `agents.templates.${tpl.id}.description`
return (
<div
key={tpl.id}
className="border-2 border-dashed border-slate-200 rounded-xl p-4 hover:border-primary/30 hover:bg-primary/[0.02] transition-all group"
>
<div className="flex items-center gap-2.5 mb-2">
<div className={`p-1.5 rounded-lg ${typeColors[tpl.type]}`}>
<Icon className="w-4 h-4" />
</div>
<h4 className="font-medium text-sm text-slate-700">{t(nameKey)}</h4>
</div>
<p className="text-xs text-slate-400 mb-3 line-clamp-2">{t(descKey)}</p>
<button
onClick={() => handleInstall(tpl)}
disabled={isInstalling}
className="flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-50"
>
{isInstalling ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
{t('agents.templates.installing')}
</>
) : (
<>
<Plus className="w-3.5 h-3.5" />
{t('agents.templates.install')}
</>
)}
</button>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Sparkles, Lightbulb, Scissors, Wand2, FileText, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
interface AIAssistantActionBarProps {
onClarify?: () => void
onShorten?: () => void
onImprove?: () => void
onTransformMarkdown?: () => void
isMarkdownMode?: boolean
disabled?: boolean
className?: string
}
export function AIAssistantActionBar({
onClarify,
onShorten,
onImprove,
onTransformMarkdown,
isMarkdownMode = false,
disabled = false,
className
}: AIAssistantActionBarProps) {
const { t } = useLanguage()
const [isExpanded, setIsExpanded] = useState(false)
const handleAction = async (action: () => void) => {
if (!disabled) {
action()
}
}
return (
<div
className={cn(
'ai-action-bar',
'bg-amber-50 dark:bg-amber-950/20',
'border border-amber-200 dark:border-amber-800',
'rounded-lg shadow-md',
'transition-all duration-200',
className
)}
>
{/* Header with toggle */}
<div
className="flex items-center justify-between px-3 py-2 cursor-pointer select-none hover:bg-amber-100/50 dark:hover:bg-amber-900/30 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<div className="p-1 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Sparkles className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
</div>
<span className="text-xs font-semibold text-amber-700 dark:text-amber-300">
{t('ai.assistant')}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 hover:bg-amber-200/50 dark:hover:bg-amber-800/30"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
>
{isExpanded ? (
<ChevronUp className="h-3 w-3 text-amber-600 dark:text-amber-400" />
) : (
<ChevronDown className="h-3 w-3 text-amber-600 dark:text-amber-400" />
)}
</Button>
</div>
{/* Actions */}
{isExpanded && (
<div className="px-3 pb-3 flex flex-wrap gap-2">
{/* Clarify */}
{onClarify && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
onClick={() => handleAction(onClarify)}
disabled={disabled}
>
<Lightbulb className="h-3 w-3 mr-1 text-amber-600 dark:text-amber-400" />
{t('ai.clarify')}
</Button>
)}
{/* Shorten */}
{onShorten && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
onClick={() => handleAction(onShorten)}
disabled={disabled}
>
<Scissors className="h-3 w-3 mr-1 text-primary dark:text-primary-foreground" />
{t('ai.shorten')}
</Button>
)}
{/* Improve Style */}
{onImprove && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-700 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
onClick={() => handleAction(onImprove)}
disabled={disabled}
>
<Wand2 className="h-3 w-3 mr-1 text-purple-600 dark:text-purple-400" />
{t('ai.improveStyle')}
</Button>
)}
{/* Transform to Markdown */}
{onTransformMarkdown && !isMarkdownMode && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs bg-white dark:bg-zinc-800 hover:bg-emerald-50 dark:hover:bg-emerald-950/20 border-emerald-300 dark:border-emerald-700 hover:border-emerald-400 dark:hover:border-emerald-600 text-emerald-700 dark:text-emerald-400 transition-colors font-medium"
onClick={() => handleAction(onTransformMarkdown)}
disabled={disabled}
>
<FileText className="h-3 w-3 mr-1" />
{t('ai.transformMarkdown') || 'Transformer en Markdown'}
</Button>
)}
{/* Already in markdown mode indicator */}
{isMarkdownMode && (
<div className="h-7 px-2 text-xs flex items-center bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 rounded border border-emerald-200 dark:border-emerald-800 font-medium">
<FileText className="h-3 w-3 mr-1" />
Markdown
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,205 @@
'use client'
import { useState } from 'react'
import { Card } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { updateAISettings } from '@/app/actions/ai-settings'
import { DemoModeToggle } from '@/components/demo-mode-toggle'
import { toast } from 'sonner'
import { Loader2 } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface AISettingsPanelProps {
initialSettings: {
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 function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
const [settings, setSettings] = useState(initialSettings)
const [isPending, setIsPending] = useState(false)
const { t } = useLanguage()
const handleToggle = async (feature: string, value: boolean) => {
// Optimistic update
setSettings(prev => ({ ...prev, [feature]: value }))
try {
setIsPending(true)
await updateAISettings({ [feature]: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating setting:', error)
toast.error(t('aiSettings.error'))
// Revert on error
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleFrequencyChange = async (value: 'daily' | 'weekly' | 'custom') => {
setSettings(prev => ({ ...prev, memoryEchoFrequency: value }))
try {
setIsPending(true)
await updateAISettings({ memoryEchoFrequency: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating frequency:', error)
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
setSettings(prev => ({ ...prev, preferredLanguage: value }))
try {
setIsPending(true)
await updateAISettings({ preferredLanguage: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating language:', error)
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleDemoModeToggle = async (enabled: boolean) => {
setSettings(prev => ({ ...prev, demoMode: enabled }))
try {
setIsPending(true)
await updateAISettings({ demoMode: enabled })
} catch (error) {
console.error('Error toggling demo mode:', error)
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
throw error
} finally {
setIsPending(false)
}
}
return (
<div className="space-y-6">
{isPending && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Loader2 className="h-4 w-4 animate-spin" />
{t('aiSettings.saving')}
</div>
)}
{/* Feature Toggles */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t('aiSettings.features')}</h2>
<FeatureToggle
name={t('titleSuggestions.available').replace('💡 ', '')}
description={t('aiSettings.titleSuggestionsDesc')}
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<FeatureToggle
name={t('semanticSearch.exactMatch')}
description={t('semanticSearch.searching')}
checked={settings.semanticSearch}
onChange={(checked) => handleToggle('semanticSearch', checked)}
/>
<FeatureToggle
name={t('paragraphRefactor.title')}
description={t('aiSettings.paragraphRefactorDesc')}
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
<FeatureToggle
name={t('memoryEcho.title')}
description={t('memoryEcho.dailyInsight')}
checked={settings.memoryEcho}
onChange={(checked) => handleToggle('memoryEcho', checked)}
/>
{settings.memoryEcho && (
<Card className="p-4 ml-6">
<Label htmlFor="frequency" className="text-sm font-medium">
{t('aiSettings.frequency')}
</Label>
<p className="text-xs text-gray-500 mb-3">
{t('aiSettings.frequencyDesc')}
</p>
<RadioGroup
value={settings.memoryEchoFrequency}
onValueChange={handleFrequencyChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="daily" id="daily" />
<Label htmlFor="daily" className="font-normal">
{t('aiSettings.frequencyDaily')}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="weekly" id="weekly" />
<Label htmlFor="weekly" className="font-normal">
{t('aiSettings.frequencyWeekly')}
</Label>
</div>
</RadioGroup>
</Card>
)}
{/* Demo Mode Toggle */}
<DemoModeToggle
demoMode={settings.demoMode}
onToggle={handleDemoModeToggle}
/>
</div>
</div>
)
}
interface FeatureToggleProps {
name: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}
function FeatureToggle({ name, description, checked, onChange }: FeatureToggleProps) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-base font-medium">{name}</Label>
<p className="text-sm text-gray-500">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onChange}
disabled={false}
/>
</div>
</Card>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { useLanguage } from '@/lib/i18n'
export function ArchiveHeader() {
const { t } = useLanguage()
return (
<h1 className="text-3xl font-bold mb-8">{t('nav.archive')}</h1>
)
}

View File

@@ -0,0 +1,227 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from './ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Checkbox } from './ui/checkbox'
import { Tag, Loader2, Sparkles, CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
interface AutoLabelSuggestionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
notebookId: string | null
onLabelsCreated: () => void
}
export function AutoLabelSuggestionDialog({
open,
onOpenChange,
notebookId,
onLabelsCreated,
}: AutoLabelSuggestionDialogProps) {
const { t } = useLanguage()
const [suggestions, setSuggestions] = useState<AutoLabelSuggestion | null>(null)
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set())
// Fetch suggestions when dialog opens with a notebook
useEffect(() => {
if (open && notebookId) {
fetchSuggestions()
} else {
// Reset state when closing
setSuggestions(null)
setSelectedLabels(new Set())
}
}, [open, notebookId])
const fetchSuggestions = async () => {
if (!notebookId) return
setLoading(true)
try {
const response = await fetch('/api/ai/auto-labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
notebookId,
language: document.documentElement.lang || 'en',
}),
})
const data = await response.json()
if (data.success && data.data) {
setSuggestions(data.data)
// Select all labels by default
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
setSelectedLabels(allLabelNames)
} else {
// No suggestions is not an error - just close the dialog
if (data.message) {
}
onOpenChange(false)
}
} catch (error) {
console.error('Failed to fetch label suggestions:', error)
toast.error(t('ai.autoLabels.error'))
onOpenChange(false)
} finally {
setLoading(false)
}
}
const toggleLabelSelection = (labelName: string) => {
const newSelected = new Set(selectedLabels)
if (newSelected.has(labelName)) {
newSelected.delete(labelName)
} else {
newSelected.add(labelName)
}
setSelectedLabels(newSelected)
}
const handleCreateLabels = async () => {
if (!suggestions || selectedLabels.size === 0) {
toast.error(t('ai.autoLabels.noLabelsSelected'))
return
}
setCreating(true)
try {
const response = await fetch('/api/ai/auto-labels', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
suggestions,
selectedLabels: Array.from(selectedLabels),
}),
})
const data = await response.json()
if (data.success) {
toast.success(t('ai.autoLabels.created', { count: data.data.createdCount }))
onLabelsCreated()
onOpenChange(false)
} else {
toast.error(data.error || t('ai.autoLabels.error'))
}
} catch (error) {
console.error('Failed to create labels:', error)
toast.error(t('ai.autoLabels.error'))
} finally {
setCreating(false)
}
}
if (loading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-4 text-sm text-muted-foreground">
{t('ai.autoLabels.analyzing')}
</p>
</div>
</DialogContent>
</Dialog>
)
}
if (!suggestions) {
return null
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
{t('ai.autoLabels.title')}
</DialogTitle>
<DialogDescription>
{t('ai.autoLabels.description', {
notebook: suggestions.notebookName,
count: suggestions.totalNotes,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
{suggestions.suggestedLabels.map((label) => (
<div
key={label.name}
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer"
onClick={() => toggleLabelSelection(label.name)}
>
<Checkbox
checked={selectedLabels.has(label.name)}
onCheckedChange={() => toggleLabelSelection(label.name)}
aria-label={`Select label: ${label.name}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{label.name}</span>
</div>
<div className="flex items-center gap-3 mt-1">
<span className="text-xs text-muted-foreground">
{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)}% {t('notebook.confidence')}
</span>
</div>
</div>
</div>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={creating}
>
{t('general.cancel')}
</Button>
<Button
onClick={handleCreateLabels}
disabled={selectedLabels.size === 0 || creating}
>
{creating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('ai.autoLabels.creating')}
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
{t('ai.autoLabels.create')}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,338 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from './ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Checkbox } from './ui/checkbox'
import { Wand2, Loader2, ChevronRight, CheckCircle2 } from 'lucide-react'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import type { OrganizationPlan, NotebookOrganization } from '@/lib/ai/services'
interface BatchOrganizationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onNotesMoved: () => void
}
export function BatchOrganizationDialog({
open,
onOpenChange,
onNotesMoved,
}: BatchOrganizationDialogProps) {
const { t, language } = useLanguage()
const [plan, setPlan] = useState<OrganizationPlan | null>(null)
const [loading, setLoading] = useState(false)
const [applying, setApplying] = useState(false)
const [selectedNotes, setSelectedNotes] = useState<Set<string>>(new Set())
const [fetchError, setFetchError] = useState<string | null>(null)
const fetchOrganizationPlan = async () => {
setLoading(true)
setFetchError(null)
try {
const response = await fetch('/api/ai/batch-organize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
language: language || 'en'
}),
})
const data = await response.json()
if (data.success && data.data) {
setPlan(data.data)
const allNoteIds = new Set<string>()
data.data.notebooks.forEach((nb: NotebookOrganization) => {
nb.notes.forEach(note => allNoteIds.add(note.noteId))
})
setSelectedNotes(allNoteIds)
} else {
const msg = data.error || t('ai.batchOrganization.error')
setFetchError(msg)
toast.error(msg)
}
} catch (error) {
console.error('Failed to create organization plan:', error)
const msg = t('ai.batchOrganization.error')
setFetchError(msg)
toast.error(msg)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (open) {
fetchOrganizationPlan()
} else {
setPlan(null)
setSelectedNotes(new Set())
setFetchError(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const handleOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen)
}
const toggleNoteSelection = (noteId: string) => {
const newSelected = new Set(selectedNotes)
if (newSelected.has(noteId)) {
newSelected.delete(noteId)
} else {
newSelected.add(noteId)
}
setSelectedNotes(newSelected)
}
const toggleNotebookSelection = (notebook: NotebookOrganization) => {
const newSelected = new Set(selectedNotes)
const allNoteIds = notebook.notes.map(n => n.noteId)
// Check if all notes in this notebook are already selected
const allSelected = allNoteIds.every(id => newSelected.has(id))
if (allSelected) {
// Deselect all
allNoteIds.forEach(id => newSelected.delete(id))
} else {
// Select all
allNoteIds.forEach(id => newSelected.add(id))
}
setSelectedNotes(newSelected)
}
const handleApply = async () => {
if (!plan || selectedNotes.size === 0) {
toast.error(t('ai.batchOrganization.noNotesSelected'))
return
}
setApplying(true)
try {
const response = await fetch('/api/ai/batch-organize', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
plan,
selectedNoteIds: Array.from(selectedNotes),
}),
})
const data = await response.json()
if (data.success) {
toast.success(t('ai.batchOrganization.success', { count: data.data.movedCount }))
onNotesMoved()
onOpenChange(false)
} else {
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
}
} catch (error) {
console.error('Failed to apply organization plan:', error)
toast.error(t('ai.batchOrganization.applyFailed'))
} finally {
setApplying(false)
}
}
const getSelectedCountForNotebook = (notebook: NotebookOrganization) => {
return notebook.notes.filter(n => selectedNotes.has(n.noteId)).length
}
const getAllSelectedCount = () => {
if (!plan) return 0
return plan.notebooks.reduce(
(acc, nb) => acc + getSelectedCountForNotebook(nb),
0
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="!max-w-5xl max-h-[85vh] overflow-y-auto !w-[95vw]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wand2 className="h-5 w-5" />
{t('ai.batchOrganization.title')}
</DialogTitle>
<DialogDescription>
{t('ai.batchOrganization.description')}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-4 text-sm text-muted-foreground">
{t('ai.batchOrganization.analyzing')}
</p>
</div>
) : fetchError ? (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-sm text-destructive text-center">{fetchError}</p>
<Button variant="outline" onClick={fetchOrganizationPlan}>
{t('general.tryAgain')}
</Button>
</div>
) : plan ? (
<div className="space-y-6 py-4">
{/* Summary */}
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>
<p className="font-medium">
{t('ai.batchOrganization.notesToOrganize', {
count: plan.totalNotes,
})}
</p>
<p className="text-sm text-muted-foreground">
{t('ai.batchOrganization.selected', {
count: getAllSelectedCount(),
})}
</p>
</div>
</div>
{/* No notebooks available */}
{plan.notebooks.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">
{plan.unorganizedNotes === plan.totalNotes
? t('ai.batchOrganization.noNotebooks')
: t('ai.batchOrganization.noSuggestions')}
</p>
</div>
) : (
<>
{/* Organization plan by notebook */}
{plan.notebooks.map((notebook) => {
const selectedCount = getSelectedCountForNotebook(notebook)
const allSelected =
selectedCount === notebook.notes.length && selectedCount > 0
return (
<div
key={notebook.notebookId}
className="border rounded-lg p-4 space-y-3"
>
{/* Notebook header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Checkbox
checked={allSelected}
onCheckedChange={() => toggleNotebookSelection(notebook)}
aria-label={t('ai.batchOrganization.selectAllIn', { notebook: notebook.notebookName })}
/>
<div className="flex items-center gap-2">
{(() => {
const Icon = getNotebookIcon(notebook.notebookIcon)
return <Icon className="h-5 w-5" />
})()}
<span className="font-semibold">
{notebook.notebookName}
</span>
<span className="text-sm text-muted-foreground">
({selectedCount}/{notebook.notes.length})
</span>
</div>
</div>
</div>
{/* Notes in this notebook */}
<div className="space-y-2 pl-11">
{notebook.notes.map((note) => (
<div
key={note.noteId}
className="flex items-start gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer"
onClick={() => toggleNoteSelection(note.noteId)}
>
<Checkbox
checked={selectedNotes.has(note.noteId)}
onCheckedChange={() => toggleNoteSelection(note.noteId)}
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">
{note.title || t('notes.untitled') || 'Untitled'}
</p>
<p className="text-xs text-muted-foreground line-clamp-2">
{note.content}
</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)}% {t('notebook.confidence')}
</span>
{note.reason && (
<span className="text-xs text-muted-foreground">
{note.reason}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
})}
{/* Unorganized notes warning */}
{plan.unorganizedNotes > 0 && (
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
<ChevronRight className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('ai.batchOrganization.unorganized', {
count: plan.unorganizedNotes,
})}
</p>
</div>
)}
</>
)}
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={applying}
>
{t('general.cancel')}
</Button>
<Button
onClick={handleApply}
disabled={!plan || selectedNotes.size === 0 || applying}
>
{applying ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('ai.batchOrganization.applying')}
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 mr-2" />
{t('ai.batchOrganization.apply', { count: selectedNotes.size })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,204 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { ChatSidebar } from './chat-sidebar'
import { ChatMessages } from './chat-messages'
import { ChatInput } from './chat-input'
import { createConversation, getConversationDetails, getConversations, deleteConversation } from '@/app/actions/chat-actions'
import { toast } from 'sonner'
import type { UIMessage } from 'ai'
import { useLanguage } from '@/lib/i18n'
interface ChatContainerProps {
initialConversations: any[]
notebooks: any[]
webSearchAvailable?: boolean
}
export function ChatContainer({ initialConversations, notebooks, webSearchAvailable }: ChatContainerProps) {
const { t, language } = useLanguage()
const [conversations, setConversations] = useState(initialConversations)
const [currentId, setCurrentId] = useState<string | null>(null)
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(undefined)
const [webSearchEnabled, setWebSearchEnabled] = useState(false)
const [historyMessages, setHistoryMessages] = useState<UIMessage[]>([])
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
// Prevents the useEffect from loading an empty conversation
// when we just created one via createConversation()
const skipHistoryLoad = useRef(false)
const scrollRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({
api: '/api/chat',
})).current
const {
messages,
sendMessage,
status,
setMessages,
} = useChat({
transport,
onError: (error) => {
toast.error(error.message || t('chat.assistantError'))
},
})
const isLoading = status === 'submitted' || status === 'streaming'
// Sync historyMessages after each completed streaming response
// so the display doesn't revert to stale history
useEffect(() => {
if (status === 'ready' && messages.length > 0) {
setHistoryMessages([...messages])
}
}, [status, messages])
// Load conversation details when the user selects a different conversation
useEffect(() => {
// Skip if we just created the conversation — useChat already has the messages
if (skipHistoryLoad.current) {
skipHistoryLoad.current = false
return
}
if (currentId) {
const loadMessages = async () => {
setIsLoadingHistory(true)
try {
const details = await getConversationDetails(currentId)
if (details) {
const loaded: UIMessage[] = details.messages.map((m: any, i: number) => ({
id: m.id || `hist-${i}`,
role: m.role as 'user' | 'assistant',
parts: [{ type: 'text' as const, text: m.content }],
}))
setHistoryMessages(loaded)
setMessages(loaded)
}
} catch (error) {
toast.error(t('chat.loadError'))
} finally {
setIsLoadingHistory(false)
}
}
loadMessages()
} else {
setMessages([])
setHistoryMessages([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentId])
const refreshConversations = useCallback(async () => {
try {
const updated = await getConversations()
setConversations(updated)
} catch {}
}, [])
const handleSendMessage = async (content: string, notebookId?: string) => {
if (notebookId) {
setSelectedNotebook(notebookId)
}
// If no active conversation, create one BEFORE streaming
let convId = currentId
if (!convId) {
try {
const result = await createConversation(content, notebookId || selectedNotebook)
convId = result.id
// Tell the useEffect to skip — we don't want to load an empty conversation
skipHistoryLoad.current = true
setCurrentId(convId)
setHistoryMessages([])
setConversations((prev) => [
{ id: result.id, title: result.title, updatedAt: new Date() },
...prev,
])
} catch {
toast.error(t('chat.createError'))
return
}
}
await sendMessage(
{ text: content },
{
body: {
conversationId: convId,
notebookId: notebookId || selectedNotebook || undefined,
language,
webSearch: webSearchEnabled,
},
}
)
}
const handleNewChat = () => {
setCurrentId(null)
setMessages([])
setHistoryMessages([])
setSelectedNotebook(undefined)
setWebSearchEnabled(false)
}
const handleDeleteConversation = async (id: string) => {
try {
await deleteConversation(id)
if (currentId === id) {
handleNewChat()
}
await refreshConversations()
} catch {
toast.error(t('chat.deleteError'))
}
}
// During streaming or if useChat has more messages than history, prefer useChat
const displayMessages = isLoading || messages.length > historyMessages.length
? messages
: historyMessages
// Auto-scroll to bottom when messages change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [displayMessages])
return (
<div className="flex-1 flex overflow-hidden bg-white dark:bg-[#1a1c22]">
<ChatSidebar
conversations={conversations}
currentId={currentId}
onSelect={setCurrentId}
onNew={handleNewChat}
onDelete={handleDeleteConversation}
/>
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-hide pb-6 w-full flex justify-center">
<ChatMessages messages={displayMessages} isLoading={isLoading || isLoadingHistory} />
</div>
<div className="w-full flex justify-center sticky bottom-0 bg-gradient-to-t from-white dark:from-[#1a1c22] via-white/90 dark:via-[#1a1c22]/90 to-transparent pt-6 pb-4">
<div className="w-full max-w-4xl px-4">
<ChatInput
onSend={handleSendMessage}
isLoading={isLoading}
notebooks={notebooks}
currentNotebookId={selectedNotebook || null}
webSearchEnabled={webSearchEnabled}
onToggleWebSearch={() => setWebSearchEnabled(prev => !prev)}
webSearchAvailable={webSearchAvailable}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,148 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { Send, BookOpen, X, Globe } from 'lucide-react'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { useLanguage } from '@/lib/i18n'
interface ChatInputProps {
onSend: (message: string, notebookId?: string) => void
isLoading?: boolean
notebooks: any[]
currentNotebookId?: string | null
webSearchEnabled?: boolean
onToggleWebSearch?: () => void
webSearchAvailable?: boolean
}
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId, webSearchEnabled, onToggleWebSearch, webSearchAvailable }: ChatInputProps) {
const { t } = useLanguage()
const [input, setInput] = useState('')
const [selectedNotebook, setSelectedNotebook] = useState<string | undefined>(currentNotebookId || undefined)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (currentNotebookId) {
setSelectedNotebook(currentNotebookId)
}
}, [currentNotebookId])
const handleSend = () => {
if (!input.trim() || isLoading) return
onSend(input, selectedNotebook)
setInput('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
}, [input])
return (
<div className="w-full relative">
<div className="relative flex flex-col bg-slate-50 dark:bg-[#202228] rounded-[24px] border border-slate-200/60 dark:border-white/10 shadow-sm focus-within:shadow-md focus-within:border-slate-300 dark:focus-within:border-white/20 transition-all duration-300 overflow-hidden">
{/* Input Area */}
<Textarea
ref={textareaRef}
placeholder={t('chat.placeholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 min-h-[56px] max-h-[40vh] bg-transparent border-none focus-visible:ring-0 resize-none py-4 px-5 text-[15px] placeholder:text-slate-400"
/>
{/* Bottom Actions Bar */}
<div className="flex items-center justify-between px-3 pb-3 pt-1">
{/* Context Selector */}
<div className="flex items-center gap-2">
<Select
value={selectedNotebook || 'global'}
onValueChange={(val) => setSelectedNotebook(val === 'global' ? undefined : val)}
>
<SelectTrigger className="h-8 w-auto min-w-[130px] rounded-full bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 shadow-sm text-xs font-medium gap-2 ring-offset-transparent focus:ring-0 focus:ring-offset-0 hover:bg-slate-50 dark:hover:bg-[#252830] transition-colors">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
<SelectValue placeholder={t('chat.allNotebooks')} />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-lg border-slate-200 dark:border-white/10">
<SelectItem value="global" className="rounded-lg text-sm text-muted-foreground">{t('chat.inAllNotebooks')}</SelectItem>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={nb.id} className="rounded-lg text-sm">
{(() => {
const Icon = getNotebookIcon(nb.icon)
return <Icon className="w-3.5 h-3.5" />
})()} {nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedNotebook && (
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary border-none rounded-full px-2.5 h-6 font-semibold tracking-wide">
{t('chat.active')}
</Badge>
)}
{webSearchAvailable && (
<button
type="button"
onClick={onToggleWebSearch}
className={cn(
"h-8 rounded-full border shadow-sm text-xs font-medium gap-1.5 flex items-center px-3 transition-all duration-200",
webSearchEnabled
? "bg-primary/10 text-primary border-primary/30 hover:bg-primary/20"
: "bg-white dark:bg-[#1a1c22] border-slate-200 dark:border-white/10 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-[#252830]"
)}
>
<Globe className="h-3.5 w-3.5" />
{webSearchEnabled && t('chat.webSearch')}
</button>
)}
</div>
{/* Send Button */}
<Button
disabled={!input.trim() || isLoading}
onClick={handleSend}
size="icon"
className={cn(
"rounded-full h-8 w-8 transition-all duration-200",
input.trim() ? "bg-primary text-primary-foreground shadow-sm hover:scale-105" : "bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500"
)}
>
<Send className="h-4 w-4 ml-0.5" />
</Button>
</div>
</div>
<div className="text-center mt-3">
<span className="text-[11px] text-muted-foreground/60 w-full block">
{t('chat.disclaimer')}
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { User, Bot, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { useLanguage } from '@/lib/i18n'
interface ChatMessagesProps {
messages: any[]
isLoading?: boolean
}
function getMessageContent(msg: any): string {
if (typeof msg.content === 'string') return msg.content
if (msg.parts && Array.isArray(msg.parts)) {
return msg.parts
.filter((p: any) => p.type === 'text')
.map((p: any) => p.text)
.join('')
}
return ''
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const { t } = useLanguage()
return (
<div className="w-full max-w-4xl flex flex-col pt-8 pb-4">
{messages.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-6">
<div className="p-5 bg-gradient-to-br from-primary/10 to-primary/5 rounded-full shadow-inner ring-1 ring-primary/10">
<Bot className="h-12 w-12 text-primary opacity-60" />
</div>
<p className="text-muted-foreground text-sm md:text-base max-w-md px-4 font-medium">
{t('chat.welcome')}
</p>
</div>
)}
{messages.map((msg, index) => {
const content = getMessageContent(msg)
const isLastAssistant = msg.role === 'assistant' && index === messages.length - 1 && isLoading
return (
<div
key={msg.id || index}
className={cn(
"flex w-full px-4 md:px-0 py-6 my-2 group",
msg.role === 'user' ? "justify-end" : "justify-start border-y border-transparent dark:border-transparent"
)}
>
{msg.role === 'user' ? (
<div dir="auto" className="max-w-[85%] md:max-w-[70%] bg-[#f4f4f5] dark:bg-[#2a2d36] text-slate-800 dark:text-slate-100 rounded-3xl rounded-br-md px-6 py-4 shadow-sm border border-slate-200/50 dark:border-white/5">
<div className="prose prose-sm dark:prose-invert max-w-none text-[15px] leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
) : (
<div className="flex gap-4 md:gap-6 w-full max-w-3xl">
<Avatar className="h-8 w-8 shrink-0 bg-transparent border border-primary/20 text-primary mt-1 shadow-sm">
<AvatarFallback className="bg-transparent"><Bot className="h-4 w-4" /></AvatarFallback>
</Avatar>
<div dir="auto" className="flex-1 overflow-hidden pt-1">
{content ? (
<div className="prose prose-slate dark:prose-invert max-w-none prose-p:leading-relaxed prose-pre:bg-slate-900 prose-pre:shadow-sm prose-pre:border prose-pre:border-slate-800 prose-headings:font-semibold marker:text-primary/50 text-[15px] prose-table:border prose-table:border-slate-300 prose-th:border prose-th:border-slate-300 prose-th:px-3 prose-th:py-2 prose-th:bg-slate-100 dark:prose-th:bg-slate-800 prose-td:border prose-td:border-slate-300 prose-td:px-3 prose-td:py-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : isLastAssistant ? (
<div className="flex items-center gap-3 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-[15px] animate-pulse">{t('chat.searching')}</span>
</div>
) : null}
</div>
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
import { MessageSquare, Trash2, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface ChatSidebarProps {
conversations: any[]
currentId?: string | null
onSelect: (id: string) => void
onNew: () => void
onDelete?: (id: string) => void
}
export function ChatSidebar({
conversations,
currentId,
onSelect,
onNew,
onDelete,
}: ChatSidebarProps) {
const { t, language } = useLanguage()
const dateLocale = language === 'fr' ? fr : enUS
const [pendingDelete, setPendingDelete] = useState<string | null>(null)
const confirmDelete = (id: string) => {
setPendingDelete(id)
}
const cancelDelete = (e: React.MouseEvent) => {
e.stopPropagation()
setPendingDelete(null)
}
const executeDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
setPendingDelete(null)
if (onDelete) {
await onDelete(id)
}
}
return (
<div className="w-64 border-r flex flex-col h-full bg-white dark:bg-[#1e2128]">
<div className="p-4 border-bottom">
<Button
onClick={onNew}
className="w-full justify-start gap-2 shadow-sm"
variant="outline"
>
<Plus className="h-4 w-4" />
{t('chat.newConversation')}
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{conversations.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
{t('chat.noHistory')}
</div>
) : (
conversations.map((chat) => (
<div
key={chat.id}
onClick={() => onSelect(chat.id)}
className={cn(
"relative cursor-pointer rounded-lg transition-all group",
currentId === chat.id
? "bg-primary/10 text-primary dark:bg-primary/20"
: "hover:bg-muted/50 text-muted-foreground"
)}
>
<div className="p-3 flex flex-col gap-1">
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 shrink-0" />
<span className="truncate text-sm font-medium pr-6">
{chat.title || t('chat.untitled')}
</span>
</div>
<span className="text-[10px] opacity-60 ml-6">
{formatDistanceToNow(new Date(chat.updatedAt), { addSuffix: true, locale: dateLocale })}
</span>
</div>
{/* Delete button — visible on hover or when confirming */}
{pendingDelete !== chat.id && (
<button
onClick={(e) => { e.stopPropagation(); confirmDelete(chat.id) }}
className="absolute top-3 right-2 opacity-0 group-hover:opacity-100 p-1 hover:text-destructive transition-all"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{/* Inline confirmation banner */}
{pendingDelete === chat.id && (
<div
className="flex items-center gap-1.5 px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t border-destructive/20 rounded-b-lg"
onClick={(e) => e.stopPropagation()}
>
<span className="flex-1 font-medium">{t('chat.deleteConfirm')}</span>
<button
onClick={(e) => executeDelete(e, chat.id)}
className="px-2 py-0.5 bg-destructive text-white rounded text-[10px] font-semibold hover:bg-destructive/90 transition-colors"
>
{t('chat.yes')}
</button>
<button
onClick={cancelDelete}
className="p-0.5 hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useLanguage } from '@/lib/i18n'
interface Collaborator {
id: string
name: string | null
email: string
image: string | null
}
interface CollaboratorAvatarsProps {
collaborators: Collaborator[]
ownerId: string
maxDisplay?: number
}
export function CollaboratorAvatars({ collaborators, ownerId, maxDisplay = 5 }: CollaboratorAvatarsProps) {
const { t } = useLanguage()
if (collaborators.length === 0) return null
const displayCollaborators = collaborators.slice(0, maxDisplay)
const remainingCount = collaborators.length - maxDisplay
return (
<div className="flex items-center gap-1 mt-2">
<TooltipProvider>
{displayCollaborators.map((collaborator) => (
<Tooltip key={collaborator.id}>
<TooltipTrigger asChild>
<div className="relative group">
<Avatar className="h-6 w-6 border-2 border-background">
<AvatarImage src={collaborator.image || undefined} />
<AvatarFallback className="text-xs">
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{collaborator.id === ownerId && (
<div className="absolute -bottom-1 -right-1">
<Badge variant="secondary" className="text-[8px] h-3 px-1 min-w-0">
{t('collaboration.owner')}
</Badge>
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{collaborator.name || t('collaboration.unnamedUser')}</p>
<p className="text-xs text-muted-foreground">{collaborator.email}</p>
</TooltipContent>
</Tooltip>
))}
{remainingCount > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center text-xs border-2 border-background">
+{remainingCount}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{remainingCount} {t('collaboration.canEdit')}</p>
</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
)
}

View File

@@ -0,0 +1,332 @@
'use client'
import { useState, useTransition, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { X, Loader2, Mail } from "lucide-react"
import { addCollaborator, removeCollaborator, getNoteCollaborators } from "@/app/actions/notes"
import { toast } from "sonner"
import { useLanguage } from "@/lib/i18n"
interface Collaborator {
id: string
name: string | null
email: string
image: string | null
}
interface CollaboratorDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
noteId: string
noteOwnerId: string
currentUserId: string
onCollaboratorsChange?: (collaboratorIds: string[]) => void
initialCollaborators?: string[]
}
export function CollaboratorDialog({
open,
onOpenChange,
noteId,
noteOwnerId,
currentUserId,
onCollaboratorsChange,
initialCollaborators = []
}: CollaboratorDialogProps) {
const router = useRouter()
const { t } = useLanguage()
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [localCollaboratorIds, setLocalCollaboratorIds] = useState<string[]>(initialCollaborators)
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isPending, startTransition] = useTransition()
const [justAddedCollaborator, setJustAddedCollaborator] = useState(false)
const isOwner = currentUserId === noteOwnerId
const isCreationMode = !noteId
const hasLoadedRef = useRef(false)
// Load collaborators when dialog opens (only for existing notes)
const loadCollaborators = async () => {
if (isCreationMode) return
setIsLoading(true)
try {
const result = await getNoteCollaborators(noteId)
setCollaborators(result)
hasLoadedRef.current = true
} catch (error: any) {
toast.error(error.message || t('collaboration.errorLoading'))
} finally {
setIsLoading(false)
}
}
// Load collaborators when dialog opens
useEffect(() => {
if (open && !isCreationMode && !hasLoadedRef.current && !isLoading) {
loadCollaborators()
}
// Reset when dialog closes
if (!open) {
hasLoadedRef.current = false
}
}, [open, isCreationMode])
// Sync initial collaborators when prop changes (creation mode)
useEffect(() => {
if (isCreationMode) {
setLocalCollaboratorIds(initialCollaborators)
}
}, [initialCollaborators, isCreationMode])
// Handle adding a collaborator
const handleAddCollaborator = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
if (isCreationMode) {
// Creation mode: just add email as placeholder, will be resolved on note creation
if (!localCollaboratorIds.includes(email)) {
const newIds = [...localCollaboratorIds, email]
setLocalCollaboratorIds(newIds)
onCollaboratorsChange?.(newIds)
setEmail('')
toast.success(t('collaboration.willBeAdded', { email }))
} else {
toast.warning(t('collaboration.alreadyInList'))
}
} else {
// Existing note mode: use server action
setJustAddedCollaborator(true)
startTransition(async () => {
try {
const result = await addCollaborator(noteId, email)
if (result.success) {
setCollaborators([...collaborators, result.user])
setEmail('')
toast.success(t('collaboration.nowHasAccess', { name: result.user.name || result.user.email }))
// Don't refresh here - it would close the dialog!
// The collaborator list is already updated in local state
setJustAddedCollaborator(false)
}
} catch (error: any) {
toast.error(error.message || t('collaboration.failedToAdd'))
setJustAddedCollaborator(false)
}
})
}
}
// Handle removing a collaborator
const handleRemoveCollaborator = async (userId: string) => {
if (isCreationMode) {
// Creation mode: remove from local list
const newIds = localCollaboratorIds.filter(id => id !== userId)
setLocalCollaboratorIds(newIds)
onCollaboratorsChange?.(newIds)
} else {
// Existing note mode: use server action
startTransition(async () => {
try {
await removeCollaborator(noteId, userId)
setCollaborators(collaborators.filter(c => c.id !== userId))
toast.success(t('collaboration.accessRevoked'))
// Don't refresh here - it would close the dialog!
// The collaborator list is already updated in local state
} catch (error: any) {
toast.error(error.message || t('collaboration.failedToRemove'))
}
})
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
// Sonner uses data-sonner-toast NOT data-toast
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
// Also prevent if target is the toast container itself
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>{t('collaboration.shareWithCollaborators')}</DialogTitle>
<DialogDescription>
{isOwner
? t('collaboration.addCollaboratorDescription')
: t('collaboration.viewerDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isOwner && (
<form onSubmit={handleAddCollaborator} className="flex gap-2">
<div className="flex-1">
<Label htmlFor="email" className="sr-only">{t('collaboration.emailAddress')}</Label>
<Input
id="email"
type="email"
placeholder={t('collaboration.enterEmailAddress')}
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isPending}
/>
</div>
<Button type="submit" disabled={isPending || !email.trim()}>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Mail className="h-4 w-4 mr-2" />
{t('collaboration.invite')}
</>
)}
</Button>
</form>
)}
<div className="space-y-2">
<Label>{t('collaboration.peopleWithAccess')}</Label>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : isCreationMode ? (
// Creation mode: show emails
localCollaboratorIds.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
{t('collaboration.noCollaborators')}
</p>
) : (
<div className="space-y-2">
{localCollaboratorIds.map((emailOrId, idx) => (
<div
key={idx}
data-testid="collaborator-item"
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
>
<div className="flex items-center gap-3 flex-1">
<Avatar className="h-8 w-8">
<AvatarFallback>
{emailOrId.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{t('collaboration.pendingInvite')}
</p>
<p className="text-xs text-muted-foreground truncate">
{emailOrId}
</p>
</div>
<Badge variant="outline" className="ml-2">{t('collaboration.pending')}</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveCollaborator(emailOrId)}
disabled={isPending}
aria-label={t('collaboration.remove')}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)
) : collaborators.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
{t('collaboration.noCollaboratorsViewer')} {isOwner && t('collaboration.noCollaborators').split('.')[1]}
</p>
) : (
<div className="space-y-2">
{collaborators.map((collaborator) => (
<div
key={collaborator.id}
data-testid="collaborator-item"
className="flex items-center justify-between p-2 rounded-lg border bg-muted/50"
>
<div className="flex items-center gap-3 flex-1">
<Avatar className="h-8 w-8">
<AvatarImage src={collaborator.image || undefined} />
<AvatarFallback>
{collaborator.name?.charAt(0).toUpperCase() || collaborator.email.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{collaborator.name || t('collaboration.unnamedUser')}
</p>
<p className="text-xs text-muted-foreground truncate">
{collaborator.email}
</p>
</div>
{collaborator.id === noteOwnerId && (
<Badge variant="secondary" className="ml-2">{t('collaboration.owner')}</Badge>
)}
</div>
{isOwner && collaborator.id !== noteOwnerId && (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRemoveCollaborator(collaborator.id)}
disabled={isPending}
aria-label={t('collaboration.remove')}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('collaboration.done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,206 @@
'use client'
import { useState } from 'react'
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Sparkles, ThumbsUp, ThumbsDown, GitMerge, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
interface ComparisonModalProps {
isOpen: boolean
onClose: () => void
notes: Array<Partial<Note>>
similarity?: number
onOpenNote?: (noteId: string) => void
onMergeNotes?: (noteIds: string[]) => void
}
export function ComparisonModal({
isOpen,
onClose,
notes,
similarity,
onOpenNote,
onMergeNotes
}: ComparisonModalProps) {
const { t } = useLanguage()
const [feedback, setFeedback] = useState<'thumbs_up' | 'thumbs_down' | null>(null)
const handleFeedback = async (type: 'thumbs_up' | 'thumbs_down') => {
setFeedback(type)
try {
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
if (noteIds.length >= 2) {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'feedback', noteIds, feedback: type })
})
}
} catch {
// silent — feedback is best-effort
}
setTimeout(() => {
onClose()
}, 500)
}
const getNoteColor = (index: number) => {
const colors = [
'border-primary/20 dark:border-primary/30 hover:border-primary/30 dark:hover:border-primary/40',
'border-purple-200 dark:border-purple-800 hover:border-purple-300 dark:hover:border-purple-700',
'border-green-200 dark:border-green-800 hover:border-green-300 dark:hover:border-green-700'
]
return colors[index % colors.length]
}
const getTitleColor = (index: number) => {
const colors = [
'text-primary dark:text-primary-foreground',
'text-purple-600 dark:text-purple-400',
'text-green-600 dark:text-green-400'
]
return colors[index % colors.length]
}
const maxModalWidth = notes.length === 2 ? 'max-w-6xl' : 'max-w-7xl'
const similarityPercentage = similarity ? Math.round(similarity * 100) : 0
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
showCloseButton={false}
className={cn(
"max-h-[90vh] overflow-hidden flex flex-col p-0",
maxModalWidth
)}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<DialogTitle className="text-xl font-semibold">
{t('memoryEcho.comparison.title')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.comparison.similarityInfo', { similarity: similarityPercentage })}
</DialogDescription>
</div>
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* AI Insight Section */}
{similarityPercentage >= 80 && (
<div className="px-6 py-4 bg-amber-50 dark:bg-amber-950/20 border-b dark:border-zinc-700">
<div className="flex items-start gap-2">
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-gray-700 dark:text-gray-300">
{t('memoryEcho.comparison.highSimilarityInsight')}
</p>
</div>
</div>
)}
{/* Notes Grid */}
<div className={cn(
"flex-1 overflow-y-auto p-6",
notes.length === 2 ? "grid grid-cols-2 gap-6" : "grid grid-cols-3 gap-4"
)}>
{notes.map((note, index) => {
const title = note.title || t('memoryEcho.comparison.untitled')
const noteColor = getNoteColor(index)
const titleColor = getTitleColor(index)
return (
<div
key={note.id || index}
onClick={() => {
if (onOpenNote && note.id) {
onOpenNote(note.id)
onClose()
}
}}
className={cn(
"cursor-pointer border dark:border-zinc-700 rounded-lg p-4 transition-all hover:shadow-md",
noteColor
)}
>
<h3 className={cn("font-semibold text-lg mb-3", titleColor)}>
{title}
</h3>
<div className="text-sm text-gray-600 dark:text-gray-400 line-clamp-8 whitespace-pre-wrap">
{note.content}
</div>
<div className="mt-4 pt-3 border-t dark:border-zinc-700">
<p className="text-xs text-gray-500 flex items-center gap-1">
{t('memoryEcho.comparison.clickToView')}
<span className="transform rotate-[-45deg]"></span>
</p>
</div>
</div>
)
})}
</div>
{/* Footer - Feedback + Actions */}
<div className="px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.comparison.helpfulQuestion')}
</p>
<Button
size="sm"
variant={feedback === 'thumbs_up' ? 'default' : 'outline'}
onClick={() => handleFeedback('thumbs_up')}
className={cn(
feedback === 'thumbs_up' && "bg-green-600 hover:bg-green-700 text-white"
)}
>
<ThumbsUp className="h-4 w-4 mr-2" />
{t('memoryEcho.comparison.helpful')}
</Button>
<Button
size="sm"
variant={feedback === 'thumbs_down' ? 'default' : 'outline'}
onClick={() => handleFeedback('thumbs_down')}
className={cn(
feedback === 'thumbs_down' && "bg-red-600 hover:bg-red-700 text-white"
)}
>
<ThumbsDown className="h-4 w-4 mr-2" />
{t('memoryEcho.comparison.notHelpful')}
</Button>
</div>
{onMergeNotes && notes.length >= 2 && (
<Button
size="sm"
className="bg-purple-600 hover:bg-purple-700 text-white"
onClick={() => {
const noteIds = notes.map(n => n.id).filter(Boolean) as string[]
onMergeNotes(noteIds)
onClose()
}}
>
<GitMerge className="h-4 w-4 mr-2" />
{t('memoryEcho.editorSection.mergeAll')}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,72 @@
'use client'
import { memo, useState, useEffect, useRef, useCallback } from 'react'
import { Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { getConnectionsCount } from '@/lib/connections-cache'
interface ConnectionsBadgeProps {
noteId: string
onClick?: () => void
className?: string
}
export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick, className }: ConnectionsBadgeProps) {
const { t } = useLanguage()
const [connectionCount, setConnectionCount] = useState<number>(0)
const [isHovered, setIsHovered] = useState(false)
const containerRef = useRef<HTMLSpanElement>(null)
const fetchedRef = useRef(false)
const fetchConnections = useCallback(async () => {
if (fetchedRef.current) return
fetchedRef.current = true
try {
const count = await getConnectionsCount(noteId)
setConnectionCount(count)
} catch {
setConnectionCount(0)
}
}, [noteId])
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
fetchConnections()
observer.disconnect()
}
},
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [fetchConnections])
if (connectionCount === 0) {
return <span ref={containerRef} className="hidden" />
}
const plural = connectionCount > 1 ? 's' : ''
const badgeText = t('memoryEcho.connectionsBadge', { count: connectionCount, plural })
return (
<div className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800 transition-all duration-150 ease-out',
isHovered && 'scale-105',
className
)}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
title={badgeText}
>
<Sparkles className="h-2.5 w-2.5 inline-block mr-1" />
{badgeText}
</div>
)
})

View File

@@ -0,0 +1,348 @@
'use client'
import { useState, useEffect } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Sparkles, X, Search, ArrowRight, Eye, GitMerge } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
interface ConnectionData {
noteId: string
title: string | null
content: string
createdAt: Date
similarity: number
daysApart: number
}
interface ConnectionsResponse {
connections: ConnectionData[]
pagination: {
total: number
page: number
limit: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
}
interface ConnectionsOverlayProps {
isOpen: boolean
onClose: () => void
noteId: string
onOpenNote?: (noteId: string) => void
onCompareNotes?: (noteIds: string[]) => void
onMergeNotes?: (noteIds: string[]) => void
}
export function ConnectionsOverlay({
isOpen,
onClose,
noteId,
onOpenNote,
onCompareNotes,
onMergeNotes
}: ConnectionsOverlayProps) {
const { t } = useLanguage()
const [connections, setConnections] = useState<ConnectionData[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Filters and sorting
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<'similarity' | 'recent' | 'oldest'>('similarity')
const [currentPage, setCurrentPage] = useState(1)
// Pagination
const [pagination, setPagination] = useState({
total: 0,
page: 1,
limit: 10,
totalPages: 0,
hasNext: false,
hasPrev: false
})
// Fetch connections when overlay opens
useEffect(() => {
if (isOpen && noteId) {
fetchConnections(1)
}
}, [isOpen, noteId])
const fetchConnections = async (page: number = 1) => {
setIsLoading(true)
setError(null)
try {
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&page=${page}&limit=10`)
if (!res.ok) {
throw new Error('Failed to fetch connections')
}
const data: ConnectionsResponse = await res.json()
setConnections(data.connections)
setPagination(data.pagination)
setCurrentPage(data.pagination.page)
} catch (err) {
console.error('[ConnectionsOverlay] Failed to fetch:', err)
setError(t('memoryEcho.overlay.error'))
} finally {
setIsLoading(false)
}
}
// Filter and sort connections
const filteredConnections = connections
.filter(conn => {
if (!searchQuery) return true
const query = searchQuery.toLowerCase()
const title = conn.title?.toLowerCase() || ''
const content = conn.content.toLowerCase()
return title.includes(query) || content.includes(query)
})
.sort((a, b) => {
switch (sortBy) {
case 'similarity':
return b.similarity - a.similarity
case 'recent':
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
case 'oldest':
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
default:
return 0
}
})
const handlePrevPage = () => {
if (pagination.hasPrev) {
fetchConnections(currentPage - 1)
}
}
const handleNextPage = () => {
if (pagination.hasNext) {
fetchConnections(currentPage + 1)
}
}
const handleOpenNote = (connNoteId: string) => {
onOpenNote?.(connNoteId)
onClose()
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col p-0"
showCloseButton={false}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Sparkles className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h2 className="text-xl font-semibold">
{t('memoryEcho.editorSection.title', { count: pagination.total })}
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.description')}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
<X className="h-6 w-6" />
</button>
</div>
{/* Filters and Search - Show if 7+ connections */}
{pagination.total >= 7 && (
<div className="px-6 py-3 border-b dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
<div className="flex items-center gap-3">
{/* Search */}
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder={t('memoryEcho.overlay.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Sort dropdown */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-2 rounded-md border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-sm"
>
<option value="similarity">{t('memoryEcho.overlay.sortSimilarity')}</option>
<option value="recent">{t('memoryEcho.overlay.sortRecent')}</option>
<option value="oldest">{t('memoryEcho.overlay.sortOldest')}</option>
</select>
</div>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">{t('memoryEcho.overlay.loading')}</div>
</div>
) : error ? (
<div className="flex items-center justify-center py-12">
<div className="text-red-500">{error}</div>
</div>
) : filteredConnections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<Search className="h-12 w-12 mb-4 opacity-50" />
<p>{t('memoryEcho.overlay.noConnections')}</p>
</div>
) : (
<div className="p-4 space-y-2">
{filteredConnections.map((conn) => {
const similarityPercentage = Math.round(conn.similarity * 100)
const title = conn.title || t('memoryEcho.comparison.untitled')
return (
<div
key={conn.noteId}
className="border dark:border-zinc-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-all hover:border-l-4 hover:border-l-amber-500 cursor-pointer"
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-base text-gray-900 dark:text-gray-100 flex-1">
{title}
</h3>
<div className="ml-2 flex items-center gap-2">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
{similarityPercentage}%
</span>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2 mb-3">
{conn.content}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleOpenNote(conn.noteId)}
className="flex-1 justify-start"
>
<Eye className="h-4 w-4 mr-2" />
{t('memoryEcho.editorSection.view')}
</Button>
{onCompareNotes && (
<Button
size="sm"
variant="ghost"
onClick={() => {
onCompareNotes([noteId, conn.noteId])
onClose()
}}
className="flex-1"
>
<ArrowRight className="h-4 w-4 mr-2" />
{t('memoryEcho.editorSection.compare')}
</Button>
)}
{onMergeNotes && (
<Button
size="sm"
variant="ghost"
onClick={() => {
onMergeNotes([noteId, conn.noteId])
onClose()
}}
className="flex-1"
>
<GitMerge className="h-4 w-4 mr-2" />
{t('memoryEcho.editorSection.merge')}
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Footer - Pagination */}
{pagination.totalPages > 1 && (
<div className="px-6 py-4 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
<div className="flex items-center justify-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handlePrevPage}
disabled={!pagination.hasPrev}
>
</Button>
<span className="text-sm text-gray-600 dark:text-gray-400">
{t('pagination.pageInfo', { current: currentPage, total: pagination.totalPages })}
</span>
<Button
size="sm"
variant="outline"
onClick={handleNextPage}
disabled={!pagination.hasNext}
>
</Button>
</div>
</div>
)}
{/* Footer - Actions */}
<div className="px-6 py-4 border-t dark:border-zinc-700">
<div className="flex items-center gap-2">
<Button
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onClick={() => {
if (onCompareNotes && connections.length > 0) {
const noteIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
onCompareNotes([noteId, ...noteIds])
}
onClose()
}}
disabled={connections.length === 0}
>
{t('memoryEcho.overlay.viewAll')}
</Button>
{onMergeNotes && connections.length > 0 && (
<Button
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
onClick={() => {
const allIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
onMergeNotes([noteId, ...allIds])
onClose()
}}
>
<GitMerge className="h-4 w-4 mr-2" />
{t('memoryEcho.editorSection.mergeAll')}
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,214 @@
'use client'
import { useState } from 'react'
import { Plus, X, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
const NOTEBOOK_ICONS = [
{ icon: Folder, name: 'folder' },
{ icon: Briefcase, name: 'briefcase' },
{ icon: FileText, name: 'document' },
{ icon: Zap, name: 'lightning' },
{ icon: BarChart3, name: 'chart' },
{ icon: Globe, name: 'globe' },
{ icon: Sparkles, name: 'sparkle' },
{ icon: Book, name: 'book' },
{ icon: Heart, name: 'heart' },
{ icon: Crown, name: 'crown' },
{ icon: Music, name: 'music' },
{ icon: Building2, name: 'building' },
]
const NOTEBOOK_COLORS = [
{ name: 'Slate', value: '#64748B', bg: 'bg-slate-500' },
{ name: 'Purple', value: '#8B5CF6', bg: 'bg-purple-500' },
{ name: 'Red', value: '#EF4444', bg: 'bg-red-500' },
{ name: 'Orange', value: '#F59E0B', bg: 'bg-orange-500' },
{ name: 'Green', value: '#10B981', bg: 'bg-green-500' },
{ name: 'Teal', value: '#14B8A6', bg: 'bg-teal-500' },
{ name: 'Gray', value: '#6B7280', bg: 'bg-gray-500' },
]
interface CreateNotebookDialogProps {
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialogProps) {
const { t } = useLanguage()
const { createNotebookOptimistic } = useNotebooks()
const [name, setName] = useState('')
const [selectedIcon, setSelectedIcon] = useState('folder')
const [selectedColor, setSelectedColor] = useState('#3B82F6')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSubmitting(true)
try {
await createNotebookOptimistic({
name: name.trim(),
icon: selectedIcon,
color: selectedColor,
})
// Close dialog — context already updated sidebar state
onOpenChange?.(false)
} catch (error) {
console.error('Failed to create notebook:', error)
} finally {
setIsSubmitting(false)
}
}
const handleReset = () => {
setName('')
setSelectedIcon('folder')
setSelectedColor('#3B82F6')
}
const SelectedIconComponent = NOTEBOOK_ICONS.find(i => i.name === selectedIcon)?.icon || Folder
return (
<Dialog open={open} onOpenChange={(val) => {
onOpenChange?.(val)
if (!val) handleReset()
}}>
<DialogContent className="sm:max-w-[500px] p-0">
<button
onClick={() => onOpenChange?.(false)}
className="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors z-10"
>
<X className="h-5 w-5" />
</button>
<DialogHeader className="px-8 pt-8 pb-4">
<DialogTitle className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
{t('notebook.createNew')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-500 dark:text-gray-400">
{t('notebook.createDescription')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="px-8 pb-8">
<div className="space-y-6">
{/* Notebook Name */}
<div>
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2 block">
{t('notebook.name')}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('notebook.namePlaceholder')}
className="w-full"
autoFocus
/>
</div>
{/* Icon Selection */}
<div>
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 block">
{t('notebook.selectIcon')}
</label>
<div className="grid grid-cols-6 gap-3">
{NOTEBOOK_ICONS.map((item) => {
const IconComponent = item.icon
const isSelected = selectedIcon === item.name
return (
<button
key={item.name}
type="button"
onClick={() => setSelectedIcon(item.name)}
className={cn(
"h-14 w-full rounded-xl border-2 flex items-center justify-center transition-all duration-200",
isSelected
? 'border-indigo-600 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600'
: 'border-gray-200 dark:border-gray-700 text-gray-400 hover:border-gray-300 dark:hover:border-gray-600'
)}
>
<IconComponent className="h-5 w-5" />
</button>
)
})}
</div>
</div>
{/* Color Selection */}
<div>
<label className="text-[11px] font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 block">
{t('notebook.selectColor')}
</label>
<div className="flex items-center gap-3">
{NOTEBOOK_COLORS.map((color) => {
const isSelected = selectedColor === color.value
return (
<button
key={color.value}
type="button"
onClick={() => setSelectedColor(color.value)}
className={cn(
"h-10 w-10 rounded-full border-2 transition-all duration-200",
isSelected
? 'border-white scale-110 shadow-lg'
: 'border-gray-200 dark:border-gray-700 hover:scale-105'
)}
style={{ backgroundColor: color.value }}
title={color.name}
/>
)
})}
</div>
</div>
{/* Preview */}
{name.trim() && (
<div className="flex items-center gap-3 p-4 rounded-xl bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white shadow-md"
style={{ backgroundColor: selectedColor }}
>
<SelectedIconComponent className="h-5 w-5" />
</div>
<span className="font-semibold text-gray-900 dark:text-white">{name.trim()}</span>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange?.(false)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
>
{t('notebook.cancel')}
</Button>
<Button
type="submit"
disabled={!name.trim() || isSubmitting}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6"
>
{isSubmitting ? t('notebook.creating') : t('notebook.create')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,9 @@
'use client'
export function DebugTheme({ theme }: { theme: string }) {
return (
<div className="fixed bottom-4 left-4 z-50 bg-black text-white p-2 rounded text-xs opacity-80 pointer-events-none">
Debug Theme: {theme}
</div>
)
}

View File

@@ -0,0 +1,54 @@
'use client'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useNotebooks } from '@/context/notebooks-context'
interface DeleteNotebookDialogProps {
notebook: any
open: boolean
onOpenChange: (open: boolean) => void
}
export function DeleteNotebookDialog({ notebook, open, onOpenChange }: DeleteNotebookDialogProps) {
const { deleteNotebook } = useNotebooks()
const { t } = useLanguage()
const handleDelete = async () => {
try {
await deleteNotebook(notebook.id)
onOpenChange(false)
} catch (error) {
// Error already handled in UI
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('notebook.delete')}</DialogTitle>
<DialogDescription>
{t('notebook.deleteWarning', { notebookName: notebook?.name })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('general.cancel')}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t('notebook.deleteConfirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,101 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { FlaskConical, Zap, Target, Lightbulb } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface DemoModeToggleProps {
demoMode: boolean
onToggle: (enabled: boolean) => Promise<void>
}
export function DemoModeToggle({ demoMode, onToggle }: DemoModeToggleProps) {
const [isPending, setIsPending] = useState(false)
const { t } = useLanguage()
const handleToggle = async (checked: boolean) => {
setIsPending(true)
try {
await onToggle(checked)
if (checked) {
toast.success(t('demoMode.activated'))
} else {
toast.success(t('demoMode.deactivated'))
}
} catch (error) {
console.error('Error toggling demo mode:', error)
toast.error(t('demoMode.toggleFailed'))
} finally {
setIsPending(false)
}
}
return (
<Card className={`border-2 transition-all ${
demoMode
? 'border-amber-300 bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/30 dark:to-background'
: 'border-amber-100 dark:border-amber-900/30'
}`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`p-2 rounded-full transition-colors ${
demoMode
? 'bg-amber-200 dark:bg-amber-900/50'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
<FlaskConical className={`h-5 w-5 ${
demoMode ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500'
}`} />
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
🧪 {t('demoMode.title')}
{demoMode && <Zap className="h-4 w-4 text-amber-500 animate-pulse" />}
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('demoMode.description')}
</CardDescription>
</div>
</div>
<Switch
checked={demoMode}
onCheckedChange={handleToggle}
disabled={isPending}
className="data-[state=checked]:bg-amber-600"
/>
</div>
</CardHeader>
{demoMode && (
<CardContent className="pt-0 space-y-2">
<div className="rounded-lg bg-white dark:bg-zinc-900 border border-amber-200 dark:border-amber-900/30 p-3">
<p className="text-xs font-semibold text-gray-700 dark:text-gray-300 mb-2">
{t('demoMode.parametersActive')}
</p>
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
<div className="flex items-start gap-2">
<Target className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
<span>{t('demoMode.similarityThreshold')}</span>
</div>
<div className="flex items-start gap-2">
<Zap className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
<span>{t('demoMode.delayBetweenNotes')}</span>
</div>
<div className="flex items-start gap-2">
<Lightbulb className="h-3.5 w-3.5 mt-0.5 text-amber-600 flex-shrink-0" />
<span>{t('demoMode.unlimitedInsights')}</span>
</div>
</div>
</div>
<p className="text-xs text-amber-700 dark:text-amber-400 text-center">
💡 {t('demoMode.createNotesTip')}
</p>
</CardContent>
)}
</Card>
)
}

View File

@@ -0,0 +1,23 @@
'use client'
import { useEffect } from 'react'
/**
* Sets document direction (RTL/LTR) on mount based on saved language.
* Runs before paint to prevent visual flash.
*/
export function DirectionInitializer() {
useEffect(() => {
try {
const lang = localStorage.getItem('user-language')
if (lang === 'fa' || lang === 'ar') {
document.documentElement.dir = 'rtl'
document.documentElement.lang = lang
} else {
document.documentElement.dir = 'ltr'
}
} catch {}
}, [])
return null
}

View File

@@ -0,0 +1,95 @@
'use client'
import { useState, useEffect } from 'react'
import { useLanguage } from '@/lib/i18n'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNotebooks } from '@/context/notebooks-context'
import { Notebook } from '@/lib/types'
interface EditNotebookDialogProps {
notebook: Notebook
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditNotebookDialog({ notebook, open, onOpenChange }: EditNotebookDialogProps) {
const { updateNotebook } = useNotebooks()
const { t } = useLanguage()
const [name, setName] = useState(notebook?.name || '')
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
if (open) {
setName(notebook?.name || '')
}
}, [open, notebook?.name])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSubmitting(true)
try {
await updateNotebook(notebook.id, { name: name.trim() })
onOpenChange(false)
} catch {
// Error handled in UI
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t('notebook.edit')}</DialogTitle>
<DialogDescription>
{t('notebook.editDescription')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
{t('notebook.name')}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('notebook.myNotebook')}
className="col-span-3"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('general.cancel')}
</Button>
<Button
type="submit"
disabled={!name.trim() || isSubmitting}
>
{isSubmitting ? t('notebook.saving') : t('general.confirm')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,255 @@
'use client'
import { useState, useEffect } from 'react'
import { ChevronDown, ChevronUp, Sparkles, Eye, ArrowRight, Link2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
interface ConnectionData {
noteId: string
title: string | null
content: string
createdAt: Date
similarity: number
daysApart: number
}
interface ConnectionsResponse {
connections: ConnectionData[]
pagination: {
total: number
page: number
limit: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
}
interface EditorConnectionsSectionProps {
noteId: string
onOpenNote?: (noteId: string) => void
onCompareNotes?: (noteIds: string[]) => void
onMergeNotes?: (noteIds: string[]) => void
}
export function EditorConnectionsSection({
noteId,
onOpenNote,
onCompareNotes,
onMergeNotes
}: EditorConnectionsSectionProps) {
const { t } = useLanguage()
const [connections, setConnections] = useState<ConnectionData[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isExpanded, setIsExpanded] = useState(true)
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
const fetchConnections = async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/ai/echo/connections?noteId=${noteId}&limit=10`)
if (!res.ok) {
throw new Error('Failed to fetch connections')
}
const data: ConnectionsResponse = await res.json()
setConnections(data.connections)
// Show section if there are connections
if (data.connections.length > 0) {
setIsVisible(true)
} else {
setIsVisible(false)
}
} catch (error) {
console.error('[EditorConnectionsSection] Failed to fetch:', error)
} finally {
setIsLoading(false)
}
}
fetchConnections()
}, [noteId])
// Don't render if no connections or if dismissed
if (!isVisible || (connections.length === 0 && !isLoading)) {
return null
}
return (
<div className="mt-6 border-t dark:border-zinc-700 pt-4">
{/* Header with toggle */}
<div
className="flex items-center justify-between cursor-pointer select-none group"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
<div className="p-1.5 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Sparkles className="h-4 w-4 text-amber-600 dark:text-amber-400" />
</div>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{t('memoryEcho.editorSection.title', { count: connections.length })}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={async (e) => {
e.stopPropagation()
// Dismiss all connections for this note
try {
await Promise.all(
connections.map(conn =>
fetch('/api/ai/echo/dismiss', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteId: noteId,
connectedNoteId: conn.noteId
})
})
)
)
setIsVisible(false)
} catch (error) {
console.error('❌ Failed to dismiss connections:', error)
}
}}
title={t('memoryEcho.editorSection.close') || 'Fermer'}
>
<X className="h-4 w-4 text-gray-500" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(!isExpanded)
}}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-500" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500" />
)}
</Button>
</div>
</div>
{/* Connections list */}
{isExpanded && (
<div className="mt-3 space-y-2 max-h-[300px] overflow-y-auto">
{isLoading ? (
<div className="text-center py-4 text-sm text-gray-500">
{t('memoryEcho.editorSection.loading')}
</div>
) : (
connections.map((conn) => {
const similarityPercentage = Math.round(conn.similarity * 100)
const title = conn.title || t('memoryEcho.comparison.untitled')
return (
<div
key={conn.noteId}
className="border dark:border-zinc-700 rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
{title}
</h4>
<span className="ml-2 text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
{similarityPercentage}%
</span>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 mb-2">
{conn.content}
</p>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs flex-1"
onClick={() => onOpenNote?.(conn.noteId)}
>
<Eye className="h-3 w-3 mr-1" />
{t('memoryEcho.editorSection.view')}
</Button>
{onCompareNotes && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs flex-1"
onClick={() => onCompareNotes([noteId, conn.noteId])}
>
<ArrowRight className="h-3 w-3 mr-1" />
{t('memoryEcho.editorSection.compare')}
</Button>
)}
{onMergeNotes && (
<Button
size="sm"
variant="ghost"
className="h-7 text-xs flex-1"
onClick={() => onMergeNotes([noteId, conn.noteId])}
>
<Link2 className="h-3 w-3 mr-1" />
{t('memoryEcho.editorSection.merge')}
</Button>
)}
</div>
</div>
)
})
)}
</div>
)}
{/* Footer actions */}
{isExpanded && connections.length > 1 && (
<div className="mt-3 flex items-center gap-2 pt-2 border-t dark:border-zinc-700">
<Button
size="sm"
variant="outline"
className="flex-1 text-xs"
onClick={() => {
if (onCompareNotes) {
const allIds = connections.slice(0, Math.min(3, connections.length)).map(c => c.noteId)
onCompareNotes([noteId, ...allIds])
}
}}
>
{t('memoryEcho.editorSection.compareAll')}
</Button>
{onMergeNotes && (
<Button
size="sm"
variant="outline"
className="flex-1 text-xs"
onClick={() => {
const allIds = connections.map(c => c.noteId)
onMergeNotes([noteId, ...allIds])
}}
>
{t('memoryEcho.editorSection.mergeAll')}
</Button>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Button } from "@/components/ui/button"
import { X } from "lucide-react"
interface EditorImagesProps {
images: string[]
onRemove: (index: number) => void
}
export function EditorImages({ images, onRemove }: EditorImagesProps) {
if (!images || images.length === 0) return null
return (
<div className="flex flex-col gap-3 mb-4">
{images.map((img, idx) => (
<div key={idx} className="relative group">
<img
src={img}
alt=""
className="h-auto rounded-lg"
/>
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-7 w-7 p-0 bg-white/90 hover:bg-white opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => onRemove(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import { useState } from 'react'
import { Note } from '@/lib/types'
import { NoteCard } from './note-card'
import { ChevronDown, ChevronUp, Pin } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useCardSizeMode } from '@/hooks/use-card-size-mode'
interface FavoritesSectionProps {
pinnedNotes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
isLoading?: boolean
}
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false)
const { t } = useLanguage()
const cardSizeMode = useCardSizeMode()
if (isLoading) {
return (
<section data-testid="favorites-section" className="mb-8">
<div className="flex items-center gap-2 mb-4 px-2 py-2">
<Pin className="w-5 h-5 text-muted-foreground animate-pulse" />
<div className="h-6 w-32 bg-muted rounded animate-pulse" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />
))}
</div>
</section>
)
}
if (pinnedNotes.length === 0) {
return null
}
return (
<section data-testid="favorites-section" className="mb-8">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
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}
aria-label={t('favorites.toggleSection') || 'Toggle pinned notes section'}
>
<div className="flex items-center gap-2">
<span className="text-2xl">📌</span>
<h2 className="text-lg font-semibold text-foreground">
{t('notes.pinnedNotes')}
<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={`favorites-grid ${cardSizeMode === 'uniform' ? 'favorites-columns' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'}`}
data-card-size-mode={cardSizeMode}
>
{pinnedNotes.map((note) => (
<NoteCard
key={note.id}
note={note}
onEdit={onEdit}
onSizeChange={(size) => onSizeChange?.(note.id, size)}
/>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,390 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { X, Link2, Sparkles, Edit, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Note } from '@/lib/types'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
interface FusionModalProps {
isOpen: boolean
onClose: () => void
notes: Array<Partial<Note>>
onConfirmFusion: (mergedNote: { title: string; content: string }, options: FusionOptions) => Promise<void>
}
interface FusionOptions {
archiveOriginals: boolean
keepAllTags: boolean
useLatestTitle: boolean
createBacklinks: boolean
}
export function FusionModal({
isOpen,
onClose,
notes,
onConfirmFusion
}: FusionModalProps) {
const { t } = useLanguage()
const [selectedNoteIds, setSelectedNoteIds] = useState<string[]>(notes.filter(n => n.id).map(n => n.id!))
const [customPrompt, setCustomPrompt] = useState('')
const [fusionPreview, setFusionPreview] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [generationError, setGenerationError] = useState<string | null>(null)
const hasGeneratedRef = useRef(false)
const [options, setOptions] = useState<FusionOptions>({
archiveOriginals: true,
keepAllTags: true,
useLatestTitle: false,
createBacklinks: false
})
const handleGenerateFusion = useCallback(async () => {
setIsGenerating(true)
setGenerationError(null)
setFusionPreview('')
try {
// Call AI API to generate fusion
const res = await fetch('/api/ai/echo/fusion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteIds: selectedNoteIds,
prompt: customPrompt
})
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Failed to generate fusion')
}
if (!data.fusedNote) {
throw new Error('No fusion content returned from API')
}
setFusionPreview(data.fusedNote)
} catch (error) {
console.error('[FusionModal] Failed to generate:', error)
const errorMessage = error instanceof Error ? error.message : t('memoryEcho.fusion.generateError')
setGenerationError(errorMessage)
} finally {
setIsGenerating(false)
}
}, [selectedNoteIds, customPrompt])
// Auto-generate fusion preview when modal opens with selected notes
useEffect(() => {
// Reset generation state when modal closes
if (!isOpen) {
hasGeneratedRef.current = false
setGenerationError(null)
setFusionPreview('')
return
}
// Generate only once when modal opens and we have 2+ notes
if (isOpen && selectedNoteIds.length >= 2 && !hasGeneratedRef.current && !isGenerating) {
hasGeneratedRef.current = true
handleGenerateFusion()
}
}, [isOpen, selectedNoteIds.length, isGenerating, handleGenerateFusion])
const handleConfirm = async () => {
if (isGenerating) {
return
}
if (!fusionPreview) {
await handleGenerateFusion()
return
}
setIsGenerating(true)
try {
// Parse the preview into title and content
const lines = fusionPreview.split('\n')
let title = ''
let content = fusionPreview.trim()
const firstLine = lines[0].trim()
if (firstLine.startsWith('#')) {
title = firstLine.replace(/^#+\s*/, '').trim()
content = lines.slice(1).join('\n').trim()
} else {
// No markdown heading — use first meaningful line as title
title = firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine
content = lines.slice(1).join('\n').trim()
}
if (!title) {
title = notes[0]?.title || t('memoryEcho.comparison.untitled')
}
await onConfirmFusion(
{ title, content },
options
)
onClose()
} finally {
setIsGenerating(false)
}
}
const selectedNotes = notes.filter(n => n.id && selectedNoteIds.includes(n.id))
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent showCloseButton={false} className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col p-0">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b dark:border-zinc-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-full">
<Link2 className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<DialogTitle className="text-xl font-semibold">
{t('memoryEcho.fusion.title')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-600 dark:text-gray-400">
{t('memoryEcho.fusion.mergeNotes', { count: selectedNoteIds.length })}
</DialogDescription>
</div>
</div>
<button
onClick={onClose}
className="p-1 rounded-md text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{/* Section 1: Note Selection */}
<div className="p-6 border-b dark:border-zinc-700">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
{t('memoryEcho.fusion.notesToMerge')}
</h3>
<div className="space-y-2">
{notes.filter(n => n.id).map((note) => (
<div
key={note.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border transition-colors",
selectedNoteIds.includes(note.id!)
? "border-purple-200 bg-purple-50 dark:bg-purple-950/20 dark:border-purple-800"
: "border-gray-200 dark:border-zinc-700 opacity-50"
)}
>
<Checkbox
id={`note-${note.id}`}
checked={selectedNoteIds.includes(note.id!)}
onCheckedChange={(checked) => {
if (checked && note.id) {
setSelectedNoteIds([...selectedNoteIds, note.id])
} else if (note.id) {
setSelectedNoteIds(selectedNoteIds.filter(id => id !== note.id))
}
}}
/>
<label
htmlFor={`note-${note.id}`}
className="flex-1 cursor-pointer"
>
<div className="font-medium text-sm text-gray-900 dark:text-gray-100">
{note.title || t('memoryEcho.comparison.untitled')}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{note.createdAt ? new Date(note.createdAt).toLocaleDateString() : t('memoryEcho.fusion.unknownDate')}
</div>
</label>
</div>
))}
</div>
</div>
{/* Section 2: Custom Prompt (Optional) */}
<div className="p-6 border-b dark:border-zinc-700">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
{t('memoryEcho.fusion.optionalPrompt')}
</h3>
<Textarea
placeholder={t('memoryEcho.fusion.promptPlaceholder')}
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
rows={3}
className="resize-none"
/>
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={handleGenerateFusion}
disabled={isGenerating || selectedNoteIds.length < 2}
>
{isGenerating ? (
<>
<Sparkles className="h-4 w-4 mr-2 animate-spin" />
{t('memoryEcho.fusion.generating')}
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
{t('memoryEcho.fusion.generateFusion')}
</>
)}
</Button>
</div>
{/* Error Message */}
{generationError && (
<div className="mx-6 mt-4 p-4 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">
{t('memoryEcho.fusion.error')}: {generationError}
</p>
</div>
)}
{/* Section 3: Preview */}
{fusionPreview && (
<div className="p-6 border-b dark:border-zinc-700">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
{t('memoryEcho.fusion.previewTitle')}
</h3>
{!isEditing && (
<Button
size="sm"
variant="ghost"
onClick={() => setIsEditing(true)}
>
<Edit className="h-4 w-4 mr-2" />
{t('memoryEcho.fusion.modify')}
</Button>
)}
</div>
{isEditing ? (
<Textarea
value={fusionPreview}
onChange={(e) => setFusionPreview(e.target.value)}
rows={10}
className="resize-none font-mono text-sm"
/>
) : (
<div className="border dark:border-zinc-700 rounded-lg p-4 bg-white dark:bg-zinc-900">
<pre className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans">
{fusionPreview}
</pre>
</div>
)}
</div>
)}
{/* Section 4: Options */}
<div className="p-6">
<h3 className="text-sm font-semibold mb-3">{t('memoryEcho.fusion.optionsTitle')}</h3>
<div className="space-y-2">
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={options.archiveOriginals}
onCheckedChange={(checked) =>
setOptions({ ...options, archiveOriginals: !!checked })
}
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{t('memoryEcho.fusion.archiveOriginals')}
</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={options.keepAllTags}
onCheckedChange={(checked) =>
setOptions({ ...options, keepAllTags: !!checked })
}
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{t('memoryEcho.fusion.keepAllTags')}
</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={options.useLatestTitle}
onCheckedChange={(checked) =>
setOptions({ ...options, useLatestTitle: !!checked })
}
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{t('memoryEcho.fusion.useLatestTitle')}
</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<Checkbox
checked={options.createBacklinks}
onCheckedChange={(checked) =>
setOptions({ ...options, createBacklinks: !!checked })
}
/>
<span className="text-sm text-gray-700 dark:text-gray-300">
{t('memoryEcho.fusion.createBacklinks')}
</span>
</label>
</div>
</div>
</div>
{/* Footer */}
<div className="p-6 border-t dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800/50">
<div className="flex items-center justify-between">
<Button
variant="ghost"
onClick={onClose}
>
{t('memoryEcho.fusion.cancel')}
</Button>
<div className="flex items-center gap-2">
{isEditing && (
<Button
variant="outline"
onClick={() => setIsEditing(false)}
>
{t('memoryEcho.fusion.finishEditing')}
</Button>
)}
<Button
onClick={handleConfirm}
disabled={selectedNoteIds.length < 2 || isGenerating}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Check className="h-4 w-4 mr-2" />
{isGenerating ? (
<>
<Sparkles className="h-4 w-4 mr-2 animate-spin" />
{t('memoryEcho.fusion.generating')}
</>
) : (
t('memoryEcho.fusion.confirmFusion')
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { TagSuggestion } from '@/lib/ai/types';
import { Loader2, Sparkles, X, CheckCircle, Plus } from 'lucide-react';
import { cn, getHashColor } from '@/lib/utils';
import { LABEL_COLORS } from '@/lib/types';
import { useLanguage } from '@/lib/i18n';
interface GhostTagsProps {
suggestions: TagSuggestion[];
addedTags: string[]; // Nouveauté : tags déjà présents sur la note
isAnalyzing: boolean;
onSelectTag: (tag: string) => void;
onDismissTag: (tag: string) => void;
className?: string;
}
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
const { t } = useLanguage()
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
const visibleSuggestions = suggestions;
// Show help message if not analyzing and no suggestions (but don't return null)
const isEmpty = !isAnalyzing && visibleSuggestions.length === 0;
// FIX: Never return null, always show something (either tags, analyzer, or help message)
// This ensures the help message "Tapez du contenu..." is always shown when needed
return (
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
{isAnalyzing && (
<div className="flex items-center text-purple-500 animate-pulse" title={t('ai.analyzing')}>
<Sparkles className="w-4 h-4" />
</div>
)}
{/* Show message when no labels suggested */}
{!isAnalyzing && visibleSuggestions.length === 0 && (
<div className="text-xs text-gray-500 italic">
{t('ai.autoLabels.typeForSuggestions')}
</div>
)}
{!isAnalyzing && visibleSuggestions.map((suggestion) => {
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase());
const colorName = getHashColor(suggestion.tag);
const colorClasses = LABEL_COLORS[colorName];
const isNewLabel = (suggestion as any).isNewLabel; // Check if this is a new label suggestion
if (isAdded) {
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus
return (
<div key={suggestion.tag} className={cn("flex items-center px-3 py-1 text-xs font-medium border rounded-full opacity-50 cursor-default", colorClasses.bg, colorClasses.text, colorClasses.border)}>
<CheckCircle className="w-3 h-3 mr-1.5" />
{suggestion.tag}
</div>
)
}
return (
<div
key={suggestion.tag}
className={cn(
"group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100",
colorClasses.bg,
colorClasses.border
)}
>
{/* Zone de validation (Clic principal) */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSelectTag(suggestion.tag);
}}
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
>
{isNewLabel && <Plus className="w-3 h-3 mr-1" />}
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}
{suggestion.tag}
{isNewLabel && <span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>}
</button>
{/* Zone de refus (Croix) */}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDismissTag(suggestion.tag);
}}
className={cn("pr-2 pl-1 hover:text-red-500 transition-colors", colorClasses.text)}
title={t('ai.ignoreSuggestion')}
>
<X className="w-3 h-3" />
</button>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client'
import { Suspense } from 'react'
import { Header } from './header'
import { useSearchParams, useRouter } from 'next/navigation'
import { useLabels } from '@/context/LabelContext'
interface HeaderWrapperProps {
onColorFilterChange?: (color: string | null) => void
user?: any
}
function HeaderContent({ onColorFilterChange, user }: HeaderWrapperProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { labels } = useLabels()
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const selectedColor = searchParams.get('color') || null
const handleLabelFilterChange = (labels: string[]) => {
const params = new URLSearchParams(searchParams.toString())
if (labels.length > 0) {
params.set('labels', labels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const handleColorFilterChange = (color: string | null) => {
const params = new URLSearchParams(searchParams.toString())
if (color) {
params.set('color', color)
} else {
params.delete('color')
}
router.push(`/?${params.toString()}`)
onColorFilterChange?.(color)
}
return (
<Header
selectedLabels={selectedLabels}
selectedColor={selectedColor}
onLabelFilterChange={handleLabelFilterChange}
onColorFilterChange={handleColorFilterChange}
user={user}
/>
)
}
export function HeaderWrapper(props: HeaderWrapperProps) {
return (
<Suspense fallback={<div className="h-16 border-b bg-background/95" />}>
<HeaderContent {...props} />
</Suspense>
)
}

View File

@@ -0,0 +1,442 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, Settings, LogOut, User, Shield, Coffee, MessageSquare, FlaskConical, Bot } from 'lucide-react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelFilter } from './label-filter'
import { NotificationPanel } from './notification-panel'
import { updateTheme } from '@/app/actions/profile'
import { useDebounce } from '@/hooks/use-debounce'
import { useLanguage } from '@/lib/i18n'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useSession, signOut } from 'next-auth/react'
interface HeaderProps {
selectedLabels?: string[]
selectedColor?: string | null
onLabelFilterChange?: (labels: string[]) => void
onColorFilterChange?: (color: string | null) => void
user?: any
}
export function Header({
selectedLabels = [],
selectedColor = null,
onLabelFilterChange,
onColorFilterChange,
user
}: HeaderProps = {}) {
const [searchQuery, setSearchQuery] = useState('')
const [theme, setTheme] = useState<'light' | 'dark'>('light')
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isSemanticSearching, setIsSemanticSearching] = useState(false)
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const { labels, setNotebookId } = useLabels()
const { t } = useLanguage()
const { data: session } = useSession()
const noSidebarMode = ['/agents', '/chat', '/lab'].some(r => pathname.startsWith(r))
// Track last pushed search to avoid infinite loops
const lastPushedSearch = useRef<string | null>(null)
const currentLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const currentSearch = searchParams.get('search') || ''
const currentColor = searchParams.get('color') || ''
const currentUser = user || session?.user
// Initialize search query from URL ONLY on mount
useEffect(() => {
setSearchQuery(currentSearch)
lastPushedSearch.current = currentSearch
}, []) // Run only once on mount
// Sync LabelContext notebookId with URL notebook parameter
const currentNotebook = searchParams.get('notebook')
useEffect(() => {
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)
useEffect(() => {
// Skip if search hasn't changed or if we already pushed this value
if (debouncedSearchQuery === lastPushedSearch.current) return
// 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)
} else {
params.delete('search')
}
const newUrl = `/?${params.toString()}`
// 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
const handleSemanticSearch = () => {
if (!searchQuery.trim()) return
// Add semantic flag to URL
const params = new URLSearchParams(searchParams.toString())
params.set('search', searchQuery)
params.set('semantic', 'true')
router.push(`/?${params.toString()}`)
// Show loading state briefly
setIsSemanticSearching(true)
setTimeout(() => setIsSemanticSearching(false), 1500)
}
useEffect(() => {
// Use 'theme-preference' to match the unified theme system
const savedTheme = localStorage.getItem('theme-preference') || currentUser?.theme || 'light'
// Don't persist on initial load to avoid unnecessary DB calls
applyTheme(savedTheme, false)
}, [currentUser])
const applyTheme = async (newTheme: string, persist = true) => {
setTheme(newTheme as any)
localStorage.setItem('theme-preference', newTheme)
// Remove all theme classes first
document.documentElement.classList.remove('dark')
document.documentElement.removeAttribute('data-theme')
if (newTheme === 'dark') {
document.documentElement.classList.add('dark')
} else if (newTheme !== 'light') {
document.documentElement.setAttribute('data-theme', newTheme)
if (newTheme === 'midnight') {
document.documentElement.classList.add('dark')
}
}
if (persist && currentUser) {
await updateTheme(newTheme)
}
}
const handleSearch = (query: string) => {
setSearchQuery(query)
// URL update is now handled by the debounced useEffect
}
const removeLabelFilter = (labelToRemove: string) => {
const newLabels = currentLabels.filter(l => l !== labelToRemove)
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) {
params.set('labels', newLabels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const removeColorFilter = () => {
const params = new URLSearchParams(searchParams.toString())
params.delete('color')
router.push(`/?${params.toString()}`)
}
const clearAllFilters = () => {
// Clear only label and color filters, keep search
const params = new URLSearchParams(searchParams.toString())
params.delete('labels')
params.delete('color')
router.push(`/?${params.toString()}`)
}
const handleFilterChange = (newLabels: string[]) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) {
params.set('labels', newLabels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const handleColorChange = (newColor: string | null) => {
const params = new URLSearchParams(searchParams.toString())
if (newColor) {
params.set('color', newColor)
} else {
params.delete('color')
}
router.push(`/?${params.toString()}`)
}
const toggleLabelFilter = (labelName: string) => {
const newLabels = currentLabels.includes(labelName)
? currentLabels.filter(l => l !== labelName)
: [...currentLabels, labelName]
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) {
params.set('labels', newLabels.join(','))
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
const NavItem = ({ href, icon: Icon, label, active, onClick }: any) => {
const content = (
<>
<Icon className={cn("h-5 w-5", active && "fill-current text-primary")} />
{label}
</>
)
if (onClick) {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2 text-left",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-700 dark:text-gray-300"
)}
style={{ minHeight: '44px' }}
aria-pressed={active}
>
{content}
</button>
)
}
return (
<Link
href={href}
onClick={() => setIsSidebarOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-r-full text-sm font-medium transition-colors mr-2",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "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>
)
}
const hasActiveFilters = currentLabels.length > 0 || !!currentColor
return (
<>
{/* Top Navigation - Style Keep */}
<header className="flex-none flex items-center justify-between whitespace-nowrap border-b border-solid border-slate-200 dark:border-slate-800 bg-white dark:bg-[#1e2128] px-6 py-3 z-20">
<div className="flex items-center gap-8">
{/* Logo MEMENTO */}
<div className="flex items-center gap-3 text-slate-900 dark:text-white cursor-pointer group" onClick={() => router.push('/')}>
<div className="size-8 bg-primary rounded-lg flex items-center justify-center text-primary-foreground shadow-sm group-hover:shadow-md transition-all">
<StickyNote className="w-5 h-5" />
</div>
<h2 className="text-xl font-bold leading-tight tracking-tight">MEMENTO</h2>
</div>
{/* 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-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 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)}
/>
</div>
</label>
</div>
<div className="flex flex-1 justify-end gap-2 items-center">
{/* Quick nav: Notes (hidden-sidebar only), Chat, Agents, Lab */}
<div className="hidden md:flex items-center gap-1 bg-slate-100 dark:bg-slate-800/60 rounded-full px-1.5 py-1">
{noSidebarMode && (
<Link
href="/"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
pathname === '/'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<StickyNote className="h-3.5 w-3.5" />
<span>{t('sidebar.notes') || 'Notes'}</span>
</Link>
)}
<Link
href="/chat"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
pathname === '/chat'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<MessageSquare className="h-3.5 w-3.5" />
<span>{t('nav.chat')}</span>
</Link>
<Link
href="/agents"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
pathname === '/agents'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<Bot className="h-3.5 w-3.5" />
<span>{t('nav.agents')}</span>
</Link>
<Link
href="/lab"
className={cn(
"flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors",
pathname === '/lab'
? "bg-white dark:bg-slate-700 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
<FlaskConical className="h-3.5 w-3.5" />
<span>{t('nav.lab')}</span>
</Link>
</div>
{/* Notifications */}
<NotificationPanel />
{/* Settings Button */}
<Link
href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
>
<Settings className="w-5 h-5" />
</Link>
{/* User Avatar Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center justify-center bg-center bg-no-repeat bg-cover rounded-full size-10 ring-2 ring-white dark:ring-slate-700 cursor-pointer shadow-sm hover:shadow-md transition-shadow bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
style={currentUser?.image ? { backgroundImage: `url(${currentUser?.image})` } : undefined}>
{!currentUser?.image && (
<span className="text-sm font-semibold">
{currentUser?.name ? currentUser.name.charAt(0).toUpperCase() : <User className="w-5 h-5" />}
</span>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
{currentUser?.name && <p className="font-medium">{currentUser.name}</p>}
{currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>}
</div>
</div>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/profile">
<User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') || 'Profile'}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/admin">
<Shield className="mr-2 h-4 w-4" />
<span>{t('nav.adminDashboard')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" />
<span>{t('auth.signOut') || 'Sign out'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* User Avatar - Removed from here */}
</div>
</header>
</>
)
}

View File

@@ -0,0 +1,415 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { getAISettings } from '@/app/actions/ai-settings'
import { getAllNotes, searchNotes } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NotesMainSection, type NotesViewMode } from '@/components/notes-main-section'
import { NotesViewToggle } from '@/components/notes-view-toggle'
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { FavoritesSection } from '@/components/favorites-section'
import { Button } from '@/components/ui/button'
import { Wand2, ChevronRight, Plus, FileText } from 'lucide-react'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
import { useNotebooks } from '@/context/notebooks-context'
import { getNotebookIcon } from '@/lib/notebook-icon'
import { cn } from '@/lib/utils'
import { LabelFilter } from '@/components/label-filter'
import { useLanguage } from '@/lib/i18n'
import { useHomeView } from '@/context/home-view-context'
// Lazy-load heavy dialogs — uniquement chargés à la demande
const NoteEditor = dynamic(
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
{ ssr: false }
)
const BatchOrganizationDialog = dynamic(
() => import('@/components/batch-organization-dialog').then(m => ({ default: m.BatchOrganizationDialog })),
{ ssr: false }
)
const AutoLabelSuggestionDialog = dynamic(
() => import('@/components/auto-label-suggestion-dialog').then(m => ({ default: m.AutoLabelSuggestionDialog })),
{ ssr: false }
)
type InitialSettings = {
showRecentNotes: boolean
notesViewMode: 'masonry' | 'tabs'
}
interface HomeClientProps {
initialNotes: Note[]
initialSettings: InitialSettings
}
export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const [notes, setNotes] = useState<Note[]>(initialNotes)
const [pinnedNotes, setPinnedNotes] = useState<Note[]>(
initialNotes.filter(n => n.isPinned)
)
const [notesViewMode, setNotesViewMode] = useState<NotesViewMode>(initialSettings.notesViewMode)
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null)
const [isLoading, setIsLoading] = useState(false) // false by default — data is pre-loaded
const [notebookSuggestion, setNotebookSuggestion] = useState<{ noteId: string; content: string } | null>(null)
const [batchOrganizationOpen, setBatchOrganizationOpen] = useState(false)
const { refreshKey } = useNoteRefresh()
const { labels } = useLabels()
const { setControls } = useHomeView()
const { shouldSuggest: shouldSuggestLabels, notebookId: suggestNotebookId, dismiss: dismissLabelSuggestion } = useAutoLabelSuggestion()
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
useEffect(() => {
if (shouldSuggestLabels && suggestNotebookId) {
setAutoLabelOpen(true)
}
}, [shouldSuggestLabels, suggestNotebookId])
const notebookFilter = searchParams.get('notebook')
const isInbox = !notebookFilter
const handleNoteCreated = useCallback((note: Note) => {
setNotes((prevNotes) => {
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
if (notebookFilter && note.notebookId !== notebookFilter) return prevNotes
if (!notebookFilter && note.notebookId) return prevNotes
if (labelFilter.length > 0) {
const noteLabels = note.labels || []
if (!noteLabels.some((label: string) => labelFilter.includes(label))) return prevNotes
}
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
}
if (search) {
router.refresh()
return prevNotes
}
const isPinned = note.isPinned || false
const pinnedNotes = prevNotes.filter(n => n.isPinned)
const unpinnedNotes = prevNotes.filter(n => !n.isPinned)
if (isPinned) {
return [note, ...pinnedNotes, ...unpinnedNotes]
} else {
return [...pinnedNotes, note, ...unpinnedNotes]
}
})
if (!note.notebookId) {
const wordCount = (note.content || '').trim().split(/\s+/).filter(w => w.length > 0).length
if (wordCount >= 20) {
setNotebookSuggestion({ noteId: note.id, content: note.content || '' })
}
}
}, [searchParams, labels, router])
const handleOpenNote = (noteId: string) => {
const note = notes.find(n => n.id === noteId)
if (note) setEditingNote({ note, readOnly: false })
}
const handleSizeChange = useCallback((noteId: string, size: 'small' | 'medium' | 'large') => {
setNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
setPinnedNotes(prev => prev.map(n => n.id === noteId ? { ...n, size } : n))
}, [])
useReminderCheck(notes)
// Rechargement uniquement pour les filtres actifs (search, labels, notebook)
// Les notes initiales suffisent sans filtre
useEffect(() => {
const search = searchParams.get('search')?.trim() || null
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
const notebook = searchParams.get('notebook')
const semanticMode = searchParams.get('semantic') === 'true'
// Pour le refreshKey (mutations), toujours recharger
// Pour les filtres, charger depuis le serveur
const hasActiveFilter = search || labelFilter.length > 0 || colorFilter
const load = async () => {
setIsLoading(true)
let allNotes = search
? await searchNotes(search, semanticMode, notebook || undefined)
: await getAllNotes()
// Filtre notebook côté client
// Shared notes appear ONLY in inbox (general notes), not in notebooks
if (notebook) {
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
} else {
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
}
// Filtre labels
if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelFilter.includes(label))
)
}
// Filtre couleur
if (colorFilter) {
const labelNamesWithColor = labels
.filter((label: any) => label.color === colorFilter)
.map((label: any) => label.name)
allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelNamesWithColor.includes(label))
)
}
// Merger avec les tailles locales pour ne pas écraser les modifications
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return allNotes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(allNotes.filter((n: any) => n.isPinned))
setIsLoading(false)
}
// Éviter le rechargement initial si les notes sont déjà chargées sans filtres
if (refreshKey > 0 || hasActiveFilter) {
const cancelled = { value: false }
load().then(() => { if (cancelled.value) return })
return () => { cancelled.value = true }
} else {
// Données initiales : filtrage inbox/notebook côté client seulement
let filtered = initialNotes
if (notebook) {
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
} else {
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
}
// Merger avec les tailles déjà modifiées localement
setNotes(prev => {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return filtered.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
setPinnedNotes(filtered.filter(n => n.isPinned))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, refreshKey])
const { notebooks } = useNotebooks()
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
useEffect(() => {
setControls({
isTabsMode: notesViewMode === 'tabs',
openNoteComposer: () => {},
})
return () => setControls(null)
}, [notesViewMode, setControls])
const handleNoteCreatedWrapper = (note: any) => {
handleNoteCreated(note)
}
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<span>{t('nav.notebooks')}</span>
<ChevronRight className="w-4 h-4" />
<span className="font-medium text-primary">{notebookName}</span>
</div>
)
const isTabs = notesViewMode === 'tabs'
return (
<div
className={cn(
'flex w-full min-h-0 flex-1 flex-col',
isTabs ? 'gap-3 py-1' : 'h-full px-2 py-6 sm:px-4 md:px-8'
)}
>
{/* Notebook Specific Header */}
{currentNotebook ? (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
<Breadcrumbs notebookName={currentNotebook.name} />
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-primary/10 dark:bg-primary/20 rounded-xl">
{(() => {
const Icon = getNotebookIcon(currentNotebook.icon || 'folder')
return (
<Icon
className={cn("w-8 h-8", !currentNotebook.color && "text-primary dark:text-primary-foreground")}
style={currentNotebook.color ? { color: currentNotebook.color } : undefined}
/>
)
})()}
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{currentNotebook.name}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
</div>
</div>
</div>
) : (
<div
className={cn(
'flex flex-col animate-in fade-in slide-in-from-top-2 duration-300',
isTabs ? 'mb-3 gap-3' : 'mb-8 gap-6'
)}
>
{!isTabs && <div className="mb-1 h-5" />}
<div className="flex items-start justify-between">
<div className="flex items-center gap-5">
<div className="p-3 bg-white border border-gray-100 dark:bg-gray-800 dark:border-gray-700 rounded-xl shadow-sm">
<FileText className="w-8 h-8 text-primary" />
</div>
<h1 className="text-4xl font-bold text-gray-900 dark:text-white tracking-tight">{t('notes.title')}</h1>
</div>
<div className="flex flex-wrap items-center gap-3">
<NotesViewToggle mode={notesViewMode} onModeChange={setNotesViewMode} />
<LabelFilter
selectedLabels={searchParams.get('labels')?.split(',').filter(Boolean) || []}
onFilterChange={(newLabels) => {
const params = new URLSearchParams(searchParams.toString())
if (newLabels.length > 0) params.set('labels', newLabels.join(','))
else params.delete('labels')
router.push(`/?${params.toString()}`)
}}
className="border-gray-200"
/>
{isInbox && !isLoading && notes.length >= 2 && (
<Button
onClick={() => setBatchOrganizationOpen(true)}
variant="outline"
className="h-10 px-4 rounded-full border-gray-200 text-gray-700 hover:bg-gray-50 gap-2 shadow-sm"
title={t('batch.organizeWithAI')}
>
<Wand2 className="h-4 w-4 text-purple-600" />
<span className="hidden sm:inline">{t('batch.organize')}</span>
</Button>
)}
</div>
</div>
</div>
)}
{!isTabs && (
<div
className={cn(
'animate-in fade-in slide-in-from-top-4 duration-300',
isTabs ? 'mb-3 w-full shrink-0' : 'mb-8'
)}
>
<NoteInput
onNoteCreated={handleNoteCreatedWrapper}
fullWidth={isTabs}
/>
</div>
)}
{isLoading ? (
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
) : (
<>
<FavoritesSection
pinnedNotes={pinnedNotes}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
/>
{notes.filter((note) => !note.isPinned).length > 0 && (
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
<NotesMainSection
viewMode={notesViewMode}
notes={notes.filter((note) => !note.isPinned)}
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
onSizeChange={handleSizeChange}
currentNotebookId={searchParams.get('notebook')}
/>
</div>
)}
{notes.filter(note => !note.isPinned).length === 0 && pinnedNotes.length === 0 && (
<div className="text-center py-8 text-gray-500">
{t('notes.emptyState')}
</div>
)}
</>
)}
<MemoryEchoNotification onOpenNote={handleOpenNote} />
{notebookSuggestion && (
<NotebookSuggestionToast
noteId={notebookSuggestion.noteId}
noteContent={notebookSuggestion.content}
onDismiss={() => setNotebookSuggestion(null)}
/>
)}
{batchOrganizationOpen && (
<BatchOrganizationDialog
open={batchOrganizationOpen}
onOpenChange={setBatchOrganizationOpen}
onNotesMoved={() => router.refresh()}
/>
)}
{autoLabelOpen && (
<AutoLabelSuggestionDialog
open={autoLabelOpen}
onOpenChange={(open) => {
setAutoLabelOpen(open)
if (!open) dismissLabelSuggestion()
}}
notebookId={suggestNotebookId}
onLabelsCreated={() => router.refresh()}
/>
)}
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import dynamic from 'next/dynamic'
import { useState, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types/types'
import '@excalidraw/excalidraw/index.css'
// Dynamic import with SSR disabled is REQUIRED for Excalidraw due to window dependencies
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false }
)
interface CanvasBoardProps {
initialData?: string
canvasId?: string
name: string
}
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const filesRef = useRef<BinaryFiles>({})
// Parse initial state safely (ONLY ON MOUNT to prevent Next.js revalidation infinite loops)
const [elements] = useState<readonly ExcalidrawElement[]>(() => {
if (initialData) {
try {
const parsed = JSON.parse(initialData)
if (parsed && Array.isArray(parsed)) {
return parsed
} else if (parsed && parsed.elements) {
// Restore binary files if present
if (parsed.files && typeof parsed.files === 'object') {
filesRef.current = parsed.files
}
return parsed.elements
}
} catch (e) {
console.error("[CanvasBoard] Failed to parse initial Excalidraw data:", e)
}
}
return []
})
// Detect dark mode from html class
useEffect(() => {
const checkDarkMode = () => {
const isDark = document.documentElement.classList.contains('dark')
setIsDarkMode(isDark)
}
checkDarkMode()
// Observer for theme changes
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
const handleChange = (
excalidrawElements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles
) => {
// Keep files ref up to date so we can include them in the save payload
if (files) filesRef.current = files
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
setSaveStatus('saving')
saveTimeoutRef.current = setTimeout(async () => {
try {
// Save both elements and binary files so images persist across page changes
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
await fetch('/api/canvas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: canvasId || null, name, data: snapshot })
})
setSaveStatus('saved')
} catch (e) {
console.error("[CanvasBoard] Save failure:", e)
setSaveStatus('error')
}
}, 2000)
}
return (
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
<Excalidraw
initialData={{ elements, files: filesRef.current }}
theme={isDarkMode ? "dark" : "light"}
onChange={handleChange}
libraryReturnUrl={typeof window !== 'undefined' ? window.location.origin + window.location.pathname + window.location.search : undefined}
UIOptions={{
canvasActions: {
loadScene: false,
saveToActiveFile: false,
clearCanvas: true
}
}}
/>
</div>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import React from 'react'
import { AlertCircle, RefreshCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface Props {
children: React.ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class CanvasErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[CanvasErrorBoundary] caught error:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 bg-destructive/5 rounded-3xl border border-destructive/20 m-6 gap-4">
<div className="p-4 bg-destructive/10 rounded-full">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<div className="text-center space-y-2">
<h3 className="text-xl font-bold">Oups ! Le Lab a rencontré un problème.</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
Une erreur inattendue est survenue lors du chargement de l'espace de dessin.
Cela peut arriver à cause d'un conflit de données ou d'une extension de navigateur.
</p>
</div>
<Button
onClick={() => window.location.reload()}
variant="outline"
className="flex items-center gap-2"
>
<RefreshCcw className="h-4 w-4" />
Recharger la page
</Button>
{process.env.NODE_ENV === 'development' && (
<pre className="mt-4 p-4 bg-black/5 rounded-lg text-xs font-mono overflow-auto max-w-full italic text-muted-foreground">
{this.state.error?.message}
</pre>
)}
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,27 @@
'use client'
import dynamic from 'next/dynamic'
import { LabSkeleton } from './lab-skeleton'
import { CanvasErrorBoundary } from './canvas-error-boundary'
const CanvasBoard = dynamic(
() => import('./canvas-board').then((mod) => mod.CanvasBoard),
{
ssr: false,
loading: () => <LabSkeleton />
}
)
interface CanvasWrapperProps {
canvasId?: string
name: string
initialData?: string
}
export function CanvasWrapper(props: CanvasWrapperProps) {
return (
<CanvasErrorBoundary>
<CanvasBoard {...props} />
</CanvasErrorBoundary>
)
}

View File

@@ -0,0 +1,191 @@
'use client'
import { FlaskConical, Plus, ChevronDown, Trash2, Layout } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { renameCanvas, deleteCanvas, createCanvas } from '@/app/actions/canvas-actions'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
interface LabHeaderProps {
canvases: any[]
currentCanvasId: string | null
onCreateCanvas: () => Promise<void>
}
export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHeaderProps) {
const router = useRouter()
const { t, language } = useLanguage()
const [isPending, startTransition] = useTransition()
const [isEditing, setIsEditing] = useState(false)
const currentCanvas = canvases.find(c => c.id === currentCanvasId)
const handleRename = async (id: string, newName: string) => {
if (!newName || newName === currentCanvas?.name) {
setIsEditing(false)
return
}
try {
await renameCanvas(id, newName)
toast.success(t('labHeader.renamed'))
router.refresh()
} catch (e) {
toast.error(t('labHeader.renameError'))
}
setIsEditing(false)
}
const handleCreate = async () => {
startTransition(async () => {
try {
const newCanvas = await createCanvas(language)
router.push(`/lab?id=${newCanvas.id}`)
toast.success(t('labHeader.created'))
} catch (e) {
toast.error(t('labHeader.createFailed'))
}
})
}
const handleDelete = async (id: string, name: string) => {
if (window.confirm(`${t('labHeader.deleteSpace')} "${name}" ?`)) {
try {
await deleteCanvas(id)
toast.success(t('labHeader.deleted'))
router.push('/lab')
router.refresh()
} catch (e) {
toast.error(t('labHeader.deleteError'))
}
}
}
return (
<header className="h-16 border-b bg-white/80 dark:bg-[#1a1c22]/80 backdrop-blur-md flex items-center justify-between px-6 z-20 shrink-0 sticky top-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 pe-4 border-e">
<div className="p-2 bg-primary/10 rounded-xl">
<FlaskConical className="h-4 w-4 text-primary" />
</div>
<div>
<h1 className="text-sm font-bold tracking-tight">{t('labHeader.title')}</h1>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">{t('labHeader.live')}</p>
</div>
</div>
</div>
{/* Project Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-10 flex items-center gap-2 hover:bg-muted/50 rounded-xl px-3 transition-all active:scale-95">
<Layout className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col items-start gap-0.5">
<span className="text-[10px] text-muted-foreground uppercase font-bold leading-none">{t('labHeader.currentProject')}</span>
<span className="text-sm font-semibold truncate max-w-[150px]">{currentCanvas?.name || t('labHeader.choose')}</span>
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground ms-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[280px] p-2 rounded-2xl shadow-xl border-muted/20">
<DropdownMenuLabel className="text-xs text-muted-foreground px-2 py-1.5 flex justify-between items-center">
{t('labHeader.yourSpaces')}
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full font-mono">{canvases.length}</span>
</DropdownMenuLabel>
<div className="space-y-1 mt-1">
{canvases.map(c => (
<div key={c.id} className="group/item flex items-center gap-1">
<DropdownMenuItem
className={cn(
"flex-1 flex flex-col items-start gap-0.5 rounded-xl cursor-pointer p-3 transition-all",
c.id === currentCanvasId ? "bg-primary/5 text-primary border border-primary/20" : "hover:bg-muted"
)}
onClick={() => router.push(`/lab?id=${c.id}`)}
>
<div className="flex items-center gap-2 w-full justify-between">
<span className="font-semibold text-sm">{c.name}</span>
{c.id === currentCanvasId && <span className="w-2 h-2 rounded-full bg-primary" />}
</div>
<span className="text-[10px] text-muted-foreground">{t('labHeader.updated')} {new Date(c.updatedAt).toLocaleDateString()}</span>
</DropdownMenuItem>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover/item:opacity-100 transition-opacity hover:text-destructive hover:bg-destructive/10"
onClick={(e) => {
e.stopPropagation()
handleDelete(c.id, c.name)
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<DropdownMenuSeparator className="my-2" />
<DropdownMenuItem
onClick={handleCreate}
disabled={isPending}
className="flex items-center gap-2 text-primary font-medium p-3 rounded-xl cursor-pointer hover:bg-primary/5"
>
<Plus className="h-4 w-4" />
{t('labHeader.newSpace')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Inline Rename — click on project name to edit */}
{currentCanvas && (
<div className="ms-2 flex items-center gap-2">
{isEditing ? (
<input
autoFocus
className="bg-muted px-3 py-1.5 rounded-lg text-sm font-medium focus:ring-2 focus:ring-primary/20 outline-none w-[200px]"
defaultValue={currentCanvas.name}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(currentCanvas.id, e.currentTarget.value)
if (e.key === 'Escape') setIsEditing(false)
}}
onBlur={(e) => handleRename(currentCanvas.id, e.target.value)}
/>
) : (
<button
onClick={() => setIsEditing(true)}
className="text-sm font-semibold text-foreground hover:text-primary transition-colors"
title={t('labHeader.rename') || 'Rename'}
>
{currentCanvas.name}
</button>
)}
</div>
)}
</div>
<div className="flex items-center gap-3">
{currentCanvas && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(currentCanvas.id, currentCanvas.name)}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/5 rounded-xl transition-all"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</header>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { Skeleton } from "@/components/ui/skeleton"
export function LabSkeleton() {
return (
<div className="flex-1 w-full h-full bg-slate-50 dark:bg-[#1a1c22] relative overflow-hidden">
{/* Mesh grid background simulation */}
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px]" />
{/* Top Menu Skeleton */}
<div className="absolute top-4 left-4 flex gap-2">
<Skeleton className="h-10 w-32 rounded-lg" />
<Skeleton className="h-10 w-10 rounded-lg" />
</div>
{/* Style Menu Skeleton (Top Right) */}
<div className="absolute top-4 right-4 flex flex-col gap-2">
<Skeleton className="h-64 w-48 rounded-2xl" />
</div>
{/* Toolbar Skeleton (Bottom Center) */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-2 bg-white/50 dark:bg-black/20 backdrop-blur-md p-2 rounded-2xl border">
{Array.from({ length: 9 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-10 rounded-xl" />
))}
</div>
{/* Loading Indicator */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex flex-col items-center gap-4 bg-white/80 dark:bg-[#252830]/80 p-8 rounded-3xl border shadow-2xl backdrop-blur-xl animate-in fade-in zoom-in duration-500">
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-1">
<h3 className="font-bold text-lg">Initialisation de l'espace</h3>
<p className="text-sm text-muted-foreground animate-pulse">Chargement de vos idées...</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LABEL_COLORS } from '@/lib/types'
import { useLabels } from '@/context/LabelContext'
interface LabelBadgeProps {
label: string
onRemove?: () => void
variant?: 'default' | 'filter' | 'clickable'
onClick?: () => void
isSelected?: boolean
isDisabled?: boolean
}
export function LabelBadge({
label,
onRemove,
variant = 'default',
onClick,
isSelected = false,
isDisabled = false,
}: LabelBadgeProps) {
const { getLabelColor } = useLabels()
const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
return (
<Badge
className={cn(
'text-xs border gap-1',
colorClasses.bg,
colorClasses.text,
colorClasses.border,
variant === 'filter' && 'cursor-pointer hover:opacity-80',
variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50',
isSelected && 'ring-2 ring-blue-500'
)}
onClick={onClick}
>
{label}
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="hover:text-red-600"
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useState, useEffect } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Filter, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelBadge } from './label-badge'
import { useLanguage } from '@/lib/i18n'
interface LabelFilterProps {
selectedLabels: string[]
onFilterChange: (labels: string[]) => void
className?: string
}
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
const { labels, loading } = useLabels()
const { t, language } = useLanguage()
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
useEffect(() => {
// Extract label names from labels array
setAllLabelNames(labels.map((l: any) => l.name).sort())
}, [labels])
const handleToggleLabel = (label: string) => {
if (selectedLabels.includes(label)) {
onFilterChange(selectedLabels.filter((l: string) => l !== label))
} else {
onFilterChange([...selectedLabels, label])
}
}
const handleClearAll = () => {
onFilterChange([])
}
if (loading || allLabelNames.length === 0) return null
return (
<div dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'} className={cn("flex items-center gap-2", className ? "" : "")}>
<DropdownMenu dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
<DropdownMenuTrigger asChild>
<Button
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
variant="outline"
size="sm"
className={cn(
"h-10 gap-2 rounded-full border border-gray-200 bg-white hover:bg-gray-50 text-gray-700 shadow-sm font-medium",
className
)}
>
<Filter className="h-4 w-4" />
{t('labels.filter') || 'Filter'}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1.5 rounded-full bg-gray-100">
{selectedLabels.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="flex items-center justify-between">
<span>{t('labels.title')}</span>
{selectedLabels.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-6 text-xs"
>
{t('general.clear')}
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Label Filters */}
<div className="max-h-64 overflow-y-auto px-1 pb-1">
{!loading && allLabelNames.map((labelName: string) => {
const isSelected = selectedLabels.includes(labelName)
return (
<div
key={labelName}
onClick={(e) => {
e.preventDefault()
handleToggleLabel(labelName)
}}
className={cn(
"flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border border-primary",
isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible"
)}>
<Check className="h-3 w-3" />
</div>
<LabelBadge
label={labelName}
/>
</div>
)
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Active filters display */}
{!loading && selectedLabels.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedLabels.map((labelName: string) => (
<LabelBadge
key={labelName}
label={labelName}
variant="filter"
onClick={() => handleToggleLabel(labelName)}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,225 @@
'use client'
import { useState } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
export interface LabelManagementDialogProps {
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
open?: boolean
onOpenChange?: (open: boolean) => void
}
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const { open, onOpenChange } = props
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null)
const controlled = open !== undefined && onOpenChange !== undefined
const handleAddLabel = async () => {
const trimmed = newLabel.trim()
if (trimmed) {
try {
await addLabel(trimmed, 'gray')
triggerRefresh()
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
}
}
}
const handleDeleteLabel = async (id: string) => {
try {
await deleteLabel(id)
triggerRefresh()
setConfirmDeleteId(null)
} catch (error) {
console.error('Failed to delete label:', error)
}
}
const handleChangeColor = async (id: string, color: LabelColorName) => {
try {
await updateLabel(id, { color })
triggerRefresh()
setEditingColorId(null)
} catch (error) {
console.error('Failed to update label color:', error)
}
}
const dialogContent = (
<DialogContent
className="max-w-md"
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>{t('labels.editLabels')}</DialogTitle>
<DialogDescription>
{t('labels.editLabelsDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Add new label */}
<div className="flex gap-2">
<Input
placeholder={t('labels.newLabelPlaceholder')}
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLabel()
}
}}
/>
<Button onClick={handleAddLabel} size="icon">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* List labels */}
<div className="max-h-[60vh] overflow-y-auto space-y-2">
{loading ? (
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
) : labels.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('labels.noLabelsFound')}</p>
) : (
labels.map((label) => {
const colorClasses = LABEL_COLORS[label.color]
const isEditing = editingColorId === label.id
return (
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
<div className="flex items-center gap-3 flex-1 relative">
<Tag className={cn("h-4 w-4", colorClasses.text)} />
<span className="font-medium text-sm">{label.name}</span>
{/* Color Picker Popover */}
{isEditing && (
<div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
<div className="grid grid-cols-5 gap-2">
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
const classes = LABEL_COLORS[color]
return (
<button
key={color}
className={cn(
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
classes.bg,
label.color === color ? 'border-gray-900 dark:border-gray-100 ring-2 ring-offset-1' : 'border-transparent'
)}
onClick={() => handleChangeColor(label.id, color)}
title={color}
/>
)
})}
</div>
</div>
)}
</div>
{confirmDeleteId === label.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 font-medium">{t('labels.confirmDeleteShort') || 'Confirmer ?'}</span>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setConfirmDeleteId(null)}>
{t('common.cancel') || 'Annuler'}
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={() => handleDeleteLabel(label.id)}>
{t('common.delete') || 'Supprimer'}
</Button>
</div>
) : (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setEditingColorId(isEditing ? null : label.id)}
title={t('labels.changeColor')}
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => setConfirmDeleteId(label.id)}
title={t('labels.deleteTooltip')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
})
)}
</div>
</div>
</DialogContent>
)
if (controlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{dialogContent}
</Dialog>
)
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" />
</Button>
</DialogTrigger>
{dialogContent}
</Dialog>
)
}

View File

@@ -0,0 +1,284 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from './ui/button'
import { Input } from './ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog'
import { Badge } from './ui/badge'
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels, Label } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n'
interface LabelManagerProps {
existingLabels: string[]
notebookId?: string | null
onUpdate: (labels: string[]) => void
}
export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelManagerProps) {
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
const { t } = useLanguage()
const [open, setOpen] = useState(false)
const [newLabel, setNewLabel] = useState('')
const [selectedLabels, setSelectedLabels] = useState<string[]>(existingLabels)
const [editingColor, setEditingColor] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Sync selected labels with existingLabels prop
useEffect(() => {
setSelectedLabels(existingLabels)
}, [existingLabels])
const handleAddLabel = async () => {
const trimmed = newLabel.trim()
setErrorMessage(null) // Clear previous error
if (trimmed && !selectedLabels.includes(trimmed)) {
try {
// NotebookId is REQUIRED for label creation (PRD R2)
if (!notebookId) {
setErrorMessage(t('labels.notebookRequired'))
console.error(t('labels.notebookRequired'))
return
}
// Get existing label color or use random
const existingLabel = labels.find(l => l.name === trimmed)
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
await addLabel(trimmed, color, notebookId)
const updated = [...selectedLabels, trimmed]
setSelectedLabels(updated)
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
const errorMsg = error instanceof Error ? error.message : 'Failed to add label'
setErrorMessage(errorMsg)
}
}
}
const handleRemoveLabel = (label: string) => {
setSelectedLabels(selectedLabels.filter(l => l !== label))
}
const handleSelectExisting = (label: string) => {
if (!selectedLabels.includes(label)) {
setSelectedLabels([...selectedLabels, label])
} else {
setSelectedLabels(selectedLabels.filter(l => l !== label))
}
}
const handleChangeColor = async (label: string, color: LabelColorName) => {
const labelObj = labels.find(l => l.name === label)
if (labelObj) {
try {
await updateLabel(labelObj.id, { color })
setEditingColor(null)
} catch (error) {
console.error('Failed to update label color:', error)
}
}
}
const handleSave = () => {
onUpdate(selectedLabels)
setOpen(false)
}
const handleCancel = () => {
setSelectedLabels(existingLabels)
setEditingColor(null)
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
handleCancel()
} else {
setOpen(true)
}
}}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Tag className="h-4 w-4 mr-2" />
{t('labels.title')}
</Button>
</DialogTrigger>
<DialogContent
className="max-w-md"
onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>{t('labels.manageLabels')}</DialogTitle>
<DialogDescription>
{t('labels.manageLabelsDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Error message */}
{errorMessage && (
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">{errorMessage}</p>
</div>
)}
{/* Add new label */}
<div className="flex gap-2">
<Input
placeholder={t('labels.newLabelPlaceholder')}
value={newLabel}
onChange={(e) => {
setNewLabel(e.target.value)
setErrorMessage(null) // Clear error when typing
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddLabel()
}
}}
/>
<Button onClick={handleAddLabel} size="sm">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Selected labels */}
{selectedLabels.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">{t('labels.selectedLabels')}</h4>
<div className="flex flex-wrap gap-2">
{selectedLabels.map((label) => {
const labelObj = labels.find(l => l.name === label)
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
const isEditing = editingColor === label
return (
<div key={label} className="relative">
{isEditing && labelObj ? (
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2">
<div className="grid grid-cols-3 gap-2">
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
const classes = LABEL_COLORS[color]
return (
<button
key={color}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
labelObj.color === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
)}
onClick={() => handleChangeColor(label, color)}
title={color}
/>
)
})}
</div>
</div>
) : null}
<Badge
className={cn(
'text-xs border cursor-pointer pr-1 flex items-center gap-1',
colorClasses.bg,
colorClasses.text,
colorClasses.border
)}
onClick={() => setEditingColor(isEditing ? null : label)}
>
<Palette className="h-3 w-3" />
{label}
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveLabel(label)
}}
className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
</div>
)
})}
</div>
</div>
)}
{/* Available labels from context */}
{!loading && labels.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">{t('labels.allLabels')}</h4>
<div className="flex flex-wrap gap-2">
{labels
.filter(label => !selectedLabels.includes(label.name))
.map((label) => {
const colorClasses = LABEL_COLORS[label.color]
return (
<Badge
key={label.id}
className={cn(
'text-xs border cursor-pointer',
colorClasses.bg,
colorClasses.text,
colorClasses.border,
'hover:opacity-80'
)}
onClick={() => handleSelectExisting(label.name)}
>
{label.name}
</Badge>
)
})}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
{t('general.cancel')}
</Button>
<Button onClick={handleSave}>{t('general.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tag, Plus, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelBadge } from './label-badge'
import { useLanguage } from '@/lib/i18n'
interface LabelSelectorProps {
selectedLabels: string[]
onLabelsChange: (labels: string[]) => void
variant?: 'default' | 'compact'
triggerLabel?: string
align?: 'start' | 'center' | 'end'
}
export function LabelSelector({
selectedLabels,
onLabelsChange,
variant = 'default',
triggerLabel,
align = 'start',
}: LabelSelectorProps) {
const { labels, loading, addLabel } = useLabels()
const { t } = useLanguage()
const [search, setSearch] = useState('')
const filteredLabels = labels.filter(l =>
l.name.toLowerCase().includes(search.toLowerCase())
)
const handleToggleLabel = (labelName: string) => {
if (selectedLabels.includes(labelName)) {
onLabelsChange(selectedLabels.filter((l) => l !== labelName))
} else {
onLabelsChange([...selectedLabels, labelName])
}
}
const handleCreateLabel = async () => {
const trimmed = search.trim()
if (trimmed) {
await addLabel(trimmed) // Let backend assign random color
onLabelsChange([...selectedLabels, trimmed])
setSearch('')
}
}
const showCreateOption = search.trim() && !labels.some(l => l.name.toLowerCase() === search.trim().toLowerCase())
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 px-2">
<Tag className={cn("h-4 w-4", triggerLabel && "mr-2")} />
{triggerLabel || t('labels.title')}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5 bg-gray-200 text-gray-800 dark:bg-zinc-700 dark:text-zinc-300">
{selectedLabels.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-64 p-0">
<div className="p-2">
<Input
placeholder={t('labels.namePlaceholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (showCreateOption) handleCreateLabel()
}
}}
/>
</div>
<div className="max-h-64 overflow-y-auto px-1 pb-1">
{loading ? (
<div className="p-2 text-sm text-gray-500 text-center">{t('general.loading')}</div>
) : (
<>
{filteredLabels.map((label) => {
const isSelected = selectedLabels.includes(label.name)
return (
<div
key={label.id}
onClick={(e) => {
e.preventDefault()
handleToggleLabel(label.name)
}}
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm hover:bg-accent hover:text-accent-foreground"
>
<div className={cn(
"h-4 w-4 border rounded flex items-center justify-center transition-colors",
isSelected ? "bg-primary border-primary text-primary-foreground" : "border-gray-400"
)}>
{isSelected && <Check className="h-3 w-3" />}
</div>
<LabelBadge label={label.name} variant="clickable" />
</div>
)
})}
{showCreateOption && (
<div
onClick={(e) => {
e.preventDefault()
handleCreateLabel()
}}
className="flex items-center gap-2 p-2 rounded-sm cursor-pointer text-sm border-t mt-1 font-medium hover:bg-accent hover:text-accent-foreground"
>
<Plus className="h-4 w-4" />
<span>{t('labels.createLabel', { name: search })}</span>
</div>
)}
{filteredLabels.length === 0 && !showCreateOption && (
<div className="p-2 text-sm text-gray-500 text-center">{t('labels.noLabelsFound')}</div>
)}
</>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,99 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { authenticate } from '@/app/actions/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { useLanguage } from '@/lib/i18n';
function LoginButton() {
const { pending } = useFormStatus();
const { t } = useLanguage();
return (
<Button className="w-full mt-4" aria-disabled={pending}>
{t('auth.signIn')}
</Button>
);
}
export function LoginForm({ allowRegister = true }: { allowRegister?: boolean }) {
const [errorMessage, dispatch] = useActionState(authenticate, undefined);
const { t } = useLanguage();
return (
<form action={dispatch} className="space-y-3">
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
<h1 className="mb-3 text-2xl font-bold">
{t('auth.signInToAccount')}
</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="email"
>
{t('auth.email')}
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="email"
type="email"
name="email"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="password"
>
{t('auth.password')}
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="password"
type="password"
name="password"
placeholder={t('auth.passwordPlaceholder')}
required
minLength={6}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end mt-2">
<Link
href="/forgot-password"
className="text-xs text-gray-500 hover:text-gray-900 underline"
>
{t('auth.forgotPassword')}
</Link>
</div>
<LoginButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
)}
</div>
{allowRegister && (
<div className="mt-4 text-center text-sm">
{t('auth.noAccount')}{' '}
<Link href="/register" className="underline">
{t('auth.signUp')}
</Link>
</div>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,31 @@
'use client'
import { memo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
interface MarkdownContentProps {
content: string
className?: string
}
export const MarkdownContent = memo(function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
a: ({ node, ...props }) => (
<a {...props} className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" />
),
}}
>
{content}
</ReactMarkdown>
</div>
)
})

View File

@@ -0,0 +1,161 @@
/**
* Masonry Grid — Deux modes d'affichage :
* 1. Variable : CSS Grid avec tailles small/medium/large
* 2. Uniform : CSS Columns masonry (comme Google Keep)
*/
/* ─── Container ──────────────────────────────────── */
.masonry-container {
width: 100%;
padding: 0 8px 40px 8px;
}
/* ═══════════════════════════════════════════════════
MODE 1 : VARIABLE (CSS Grid avec tailles différentes)
═══════════════════════════════════════════════════ */
.masonry-css-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: auto;
gap: 12px;
align-items: start;
grid-auto-flow: dense;
}
.masonry-sortable-item[data-size="medium"] {
grid-column: span 2;
}
.masonry-sortable-item[data-size="large"] {
grid-column: span 3;
}
/* ═══════════════════════════════════════════════════
MODE 2 : UNIFORM — CSS Columns masonry (Google Keep)
═══════════════════════════════════════════════════ */
.masonry-container[data-card-size-mode="uniform"] .masonry-css-grid {
display: block;
column-width: 240px;
column-gap: 12px;
orphans: 1;
widows: 1;
}
.masonry-container[data-card-size-mode="uniform"] .masonry-sortable-item,
.masonry-container[data-card-size-mode="uniform"] .masonry-sortable-item[data-size="medium"],
.masonry-container[data-card-size-mode="uniform"] .masonry-sortable-item[data-size="large"] {
break-inside: avoid;
margin-bottom: 12px;
display: inline-block;
width: 100%;
grid-column: unset;
}
/* ─── Sortable items ─────────────────────────────── */
.masonry-sortable-item {
break-inside: avoid;
box-sizing: border-box;
will-change: transform;
transition: opacity 0.15s ease-out;
}
/* ─── Note card base ─────────────────────────────── */
.note-card {
width: 100% !important;
min-width: 0;
box-sizing: border-box;
}
/* ─── Drag overlay ───────────────────────────────── */
.masonry-drag-overlay {
cursor: grabbing;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15);
border-radius: 12px;
opacity: 0.95;
pointer-events: none;
}
/* ─── Mobile (< 480px) ───────────────────────────── */
@media (max-width: 479px) {
.masonry-css-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.masonry-sortable-item[data-size="medium"],
.masonry-sortable-item[data-size="large"] {
grid-column: span 1;
}
.masonry-container[data-card-size-mode="uniform"] .masonry-css-grid {
column-width: 100%;
column-gap: 10px;
}
.masonry-container {
padding: 0 4px 16px 4px;
}
}
/* ─── Small tablet (480767px) ───────────────────── */
@media (min-width: 480px) and (max-width: 767px) {
.masonry-css-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.masonry-sortable-item[data-size="large"] {
grid-column: span 2;
}
.masonry-container {
padding: 0 8px 20px 8px;
}
}
/* ─── Tablet (7681023px) ────────────────────────── */
@media (min-width: 768px) and (max-width: 1023px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
}
/* ─── Desktop (10241279px) ─────────────────────── */
@media (min-width: 1024px) and (max-width: 1279px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
}
}
/* ─── Large Desktop (1280px+) ───────────────────── */
@media (min-width: 1280px) {
.masonry-css-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.masonry-container {
max-width: 1600px;
margin: 0 auto;
padding: 0 12px 32px 12px;
}
}
/* ─── Print ──────────────────────────────────────── */
@media print {
.masonry-sortable-item {
break-inside: avoid;
page-break-inside: avoid;
}
}
/* ─── Reduced motion ─────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
.masonry-sortable-item {
transition: none;
}
}

View File

@@ -0,0 +1,311 @@
'use client'
import { useState, useEffect, useCallback, memo, useMemo, useRef } from 'react';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
rectSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Note } from '@/lib/types';
import { NoteCard } from './note-card';
import { updateFullOrderWithoutRevalidation } from '@/app/actions/notes';
import { useNotebookDrag } from '@/context/notebook-drag-context';
import { useLanguage } from '@/lib/i18n';
import { useCardSizeMode } from '@/hooks/use-card-size-mode';
import dynamic from 'next/dynamic';
import './masonry-grid.css';
// Lazy-load NoteEditor — uniquement chargé au clic
const NoteEditor = dynamic(
() => import('./note-editor').then(m => ({ default: m.NoteEditor })),
{ ssr: false }
);
interface MasonryGridProps {
notes: Note[];
onEdit?: (note: Note, readOnly?: boolean) => void;
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
isTrashView?: boolean;
}
// ─────────────────────────────────────────────
// Sortable Note Item
// ─────────────────────────────────────────────
interface SortableNoteProps {
note: Note;
onEdit: (note: Note, readOnly?: boolean) => void;
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
onDragStartNote?: (noteId: string) => void;
onDragEndNote?: () => void;
isDragging?: boolean;
isOverlay?: boolean;
isTrashView?: boolean;
}
const SortableNoteItem = memo(function SortableNoteItem({
note,
onEdit,
onSizeChange,
onDragStartNote,
onDragEndNote,
isDragging,
isOverlay,
isTrashView,
}: SortableNoteProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id: note.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isSortableDragging && !isOverlay ? 0.3 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="masonry-sortable-item"
data-id={note.id}
data-size={note.size}
>
<NoteCard
note={note}
onEdit={onEdit}
onDragStart={onDragStartNote}
onDragEnd={onDragEndNote}
isDragging={isDragging}
isTrashView={isTrashView}
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
/>
</div>
);
})
// ─────────────────────────────────────────────
// Sortable Grid Section (pinned or others)
// ─────────────────────────────────────────────
interface SortableGridSectionProps {
notes: Note[];
onEdit: (note: Note, readOnly?: boolean) => void;
onSizeChange: (noteId: string, newSize: 'small' | 'medium' | 'large') => void;
draggedNoteId: string | null;
onDragStartNote: (noteId: string) => void;
onDragEndNote: () => void;
isTrashView?: boolean;
}
const SortableGridSection = memo(function SortableGridSection({
notes,
onEdit,
onSizeChange,
draggedNoteId,
onDragStartNote,
onDragEndNote,
isTrashView,
}: SortableGridSectionProps) {
const ids = useMemo(() => notes.map(n => n.id), [notes]);
return (
<SortableContext items={ids} strategy={rectSortingStrategy}>
<div className="masonry-css-grid">
{notes.map(note => (
<SortableNoteItem
key={note.id}
note={note}
onEdit={onEdit}
onSizeChange={onSizeChange}
onDragStartNote={onDragStartNote}
onDragEndNote={onDragEndNote}
isDragging={draggedNoteId === note.id}
isTrashView={isTrashView}
/>
))}
</div>
</SortableContext>
);
});
// ─────────────────────────────────────────────
// Main MasonryGrid component
// ─────────────────────────────────────────────
export function MasonryGrid({ notes, onEdit, onSizeChange, isTrashView }: MasonryGridProps) {
const { t } = useLanguage();
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly?: boolean } | null>(null);
const { startDrag, endDrag, draggedNoteId } = useNotebookDrag();
const cardSizeMode = useCardSizeMode();
const isUniformMode = cardSizeMode === 'uniform';
// Local notes state for optimistic size/order updates
const [localNotes, setLocalNotes] = useState<Note[]>(notes);
useEffect(() => {
setLocalNotes(prev => {
const prevIds = prev.map(n => n.id).join(',')
const incomingIds = notes.map(n => n.id).join(',')
if (prevIds === incomingIds) {
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
}
// Notes added/removed: full sync but preserve local sizes
const localSizeMap = new Map(prev.map(n => [n.id, n.size]))
return notes.map(n => ({ ...n, size: localSizeMap.get(n.id) ?? n.size }))
})
}, [notes]);
const pinnedNotes = useMemo(() => localNotes.filter(n => n.isPinned), [localNotes]);
const othersNotes = useMemo(() => localNotes.filter(n => !n.isPinned), [localNotes]);
const [activeId, setActiveId] = useState<string | null>(null);
const activeNote = useMemo(
() => localNotes.find(n => n.id === activeId) ?? null,
[localNotes, activeId]
);
const handleEdit = useCallback((note: Note, readOnly?: boolean) => {
if (onEdit) {
onEdit(note, readOnly);
} else {
setEditingNote({ note, readOnly });
}
}, [onEdit]);
const handleSizeChange = useCallback((noteId: string, newSize: 'small' | 'medium' | 'large') => {
setLocalNotes(prev => prev.map(n => n.id === noteId ? { ...n, size: newSize } : n));
onSizeChange?.(noteId, newSize);
}, [onSizeChange]);
// @dnd-kit sensors — pointer (desktop) + touch (mobile)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, // Évite les activations accidentelles
}),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 8 }, // Long-press sur mobile
})
);
const localNotesRef = useRef<Note[]>(localNotes)
useEffect(() => {
localNotesRef.current = localNotes
}, [localNotes])
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
startDrag(event.active.id as string);
}, [startDrag]);
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
endDrag();
if (!over || active.id === over.id) return;
const reordered = arrayMove(
localNotesRef.current,
localNotesRef.current.findIndex(n => n.id === active.id),
localNotesRef.current.findIndex(n => n.id === over.id),
);
if (reordered.length === 0) return;
setLocalNotes(reordered);
// Persist order outside of setState to avoid "setState in render" warning
const ids = reordered.map(n => n.id);
updateFullOrderWithoutRevalidation(ids).catch(err => {
console.error('Failed to persist order:', err);
});
}, [endDrag]);
return (
<DndContext
id="masonry-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="masonry-container" data-card-size-mode={cardSizeMode}>
{pinnedNotes.length > 0 && (
<div className="mb-8">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
{t('notes.pinned')}
</h2>
<SortableGridSection
notes={pinnedNotes}
onEdit={handleEdit}
onSizeChange={handleSizeChange}
draggedNoteId={draggedNoteId}
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
/>
</div>
)}
{othersNotes.length > 0 && (
<div>
{pinnedNotes.length > 0 && (
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3 px-2">
{t('notes.others')}
</h2>
)}
<SortableGridSection
notes={othersNotes}
onEdit={handleEdit}
onSizeChange={handleSizeChange}
draggedNoteId={draggedNoteId}
onDragStartNote={startDrag}
onDragEndNote={endDrag}
isTrashView={isTrashView}
/>
</div>
)}
</div>
{/* DragOverlay — montre une copie flottante pendant le drag */}
<DragOverlay>
{activeNote ? (
<div className="masonry-sortable-item masonry-drag-overlay" data-size={activeNote.size}>
<NoteCard
note={activeNote}
onEdit={handleEdit}
isDragging={true}
onSizeChange={(newSize) => handleSizeChange(activeNote.id, newSize)}
/>
</div>
) : null}
</DragOverlay>
{editingNote && (
<NoteEditor
note={editingNote.note}
readOnly={editingNote.readOnly}
onClose={() => setEditingNote(null)}
/>
)}
</DndContext>
);
}

View File

@@ -0,0 +1,478 @@
'use client'
import { useState, useTransition } from 'react'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import {
Info,
Key,
Server,
Plus,
Copy,
Check,
Trash2,
Ban,
Loader2,
ExternalLink,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import {
generateMcpKey,
revokeMcpKey,
deleteMcpKey,
type McpKeyInfo,
type McpServerStatus,
} from '@/app/actions/mcp-keys'
interface McpSettingsPanelProps {
initialKeys: McpKeyInfo[]
serverStatus: McpServerStatus
}
export function McpSettingsPanel({ initialKeys, serverStatus }: McpSettingsPanelProps) {
const [keys, setKeys] = useState<McpKeyInfo[]>(initialKeys)
const [createOpen, setCreateOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const { t } = useLanguage()
const handleGenerate = async (name: string) => {
startTransition(async () => {
try {
const result = await generateMcpKey(name)
setCreateOpen(false)
// Show the raw key in a new dialog
setShowRawKey(result.rawKey)
setRawKeyName(result.info.name)
// Refresh keys
setKeys(prev => [
{
shortId: result.info.shortId,
name: result.info.name,
userId: '',
userName: '',
active: true,
createdAt: new Date().toISOString(),
lastUsedAt: null,
},
...prev,
])
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to generate key')
}
})
}
const handleRevoke = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmRevoke'))) return
startTransition(async () => {
try {
await revokeMcpKey(shortId)
setKeys(prev =>
prev.map(k => (k.shortId === shortId ? { ...k, active: false } : k))
)
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to revoke key')
}
})
}
const handleDelete = (shortId: string) => {
if (!confirm(t('mcpSettings.apiKeys.confirmDelete'))) return
startTransition(async () => {
try {
await deleteMcpKey(shortId)
setKeys(prev => prev.filter(k => k.shortId !== shortId))
toast.success(t('toast.operationSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete key')
}
})
}
// Raw key display state
const [showRawKey, setShowRawKey] = useState<string | null>(null)
const [rawKeyName, setRawKeyName] = useState('')
const [copied, setCopied] = useState(false)
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-6">
{/* Section 1: What is MCP */}
<Card className="p-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.whatIsMcp.title')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{t('mcpSettings.whatIsMcp.description')}
</p>
<a
href="https://modelcontextprotocol.io"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline mt-2"
>
{t('mcpSettings.whatIsMcp.learnMore')}
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</Card>
{/* Section 2: Server Status */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<Server className="h-5 w-5 shrink-0" />
<h2 className="text-lg font-semibold">{t('mcpSettings.serverStatus.title')}</h2>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.mode')}:</span>
<Badge variant="secondary">{serverStatus.mode.toUpperCase()}</Badge>
</div>
{serverStatus.mode === 'sse' && serverStatus.url && (
<div className="flex items-center gap-2">
<span className="text-gray-500">{t('mcpSettings.serverStatus.url')}:</span>
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
{serverStatus.url}
</code>
</div>
)}
</div>
</Card>
{/* Section 3: API Keys */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">{t('mcpSettings.apiKeys.title')}</h2>
<p className="text-sm text-gray-500">
{t('mcpSettings.apiKeys.description')}
</p>
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5">
<Plus className="h-4 w-4" />
{t('mcpSettings.apiKeys.generate')}
</Button>
</DialogTrigger>
<CreateKeyDialog
onGenerate={handleGenerate}
isPending={isPending}
/>
</Dialog>
</div>
{keys.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Key className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>{t('mcpSettings.apiKeys.empty')}</p>
</div>
) : (
<div className="space-y-3">
{keys.map(k => (
<KeyCard
key={k.shortId}
keyInfo={k}
onRevoke={handleRevoke}
onDelete={handleDelete}
isPending={isPending}
/>
))}
</div>
)}
</Card>
{/* Section 4: Configuration Instructions */}
<ConfigInstructions serverStatus={serverStatus} />
{/* Raw Key Display Dialog */}
<Dialog open={!!showRawKey} onOpenChange={(open) => { if (!open) setShowRawKey(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.successTitle')}</DialogTitle>
<DialogDescription>
{t('mcpSettings.createDialog.successDescription')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-gray-500">{rawKeyName}</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded break-all font-mono">
{showRawKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(showRawKey!)}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setShowRawKey(null)}>
{t('mcpSettings.createDialog.done')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ── Sub-components ──────────────────────────────────────────────────────────────
function CreateKeyDialog({
onGenerate,
isPending,
}: {
onGenerate: (name: string) => void
isPending: boolean
}) {
const [name, setName] = useState('')
const { t } = useLanguage()
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcpSettings.createDialog.title')}</DialogTitle>
<DialogDescription>
{t('mcpSettings.createDialog.description')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label htmlFor="key-name">{t('mcpSettings.createDialog.nameLabel')}</Label>
<Input
id="key-name"
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
value={name}
onChange={e => setName(e.target.value)}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => onGenerate(name)}
disabled={isPending}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-1" />
{t('mcpSettings.createDialog.generating')}
</>
) : (
<>
<Key className="h-4 w-4 mr-1" />
{t('mcpSettings.createDialog.generate')}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
)
}
function KeyCard({
keyInfo,
onRevoke,
onDelete,
isPending,
}: {
keyInfo: McpKeyInfo
onRevoke: (shortId: string) => void
onDelete: (shortId: string) => void
isPending: boolean
}) {
const { t } = useLanguage()
const formatDate = (iso: string | null) => {
if (!iso) return t('mcpSettings.apiKeys.never')
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<div className="flex items-center justify-between p-4 rounded-lg border bg-gray-50 dark:bg-gray-900">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{keyInfo.name}</span>
<Badge variant={keyInfo.active ? 'default' : 'secondary'} className="text-xs">
{keyInfo.active
? t('mcpSettings.apiKeys.active')
: t('mcpSettings.apiKeys.revoked')}
</Badge>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<span>
{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}
</span>
<span>
{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}
</span>
</div>
</div>
<div>
{keyInfo.active ? (
<Button
size="sm"
variant="outline"
onClick={() => onRevoke(keyInfo.shortId)}
disabled={isPending}
className="gap-1"
>
<Ban className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.revoke')}
</Button>
) : (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(keyInfo.shortId)}
disabled={isPending}
className="gap-1"
>
<Trash2 className="h-3.5 w-3.5" />
{t('mcpSettings.apiKeys.delete')}
</Button>
)}
</div>
</div>
)
}
function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus }) {
const { t } = useLanguage()
const [expanded, setExpanded] = useState<string | null>(null)
const baseUrl = serverStatus.url || 'http://localhost:3001'
const configs = [
{
id: 'claude-code',
title: t('mcpSettings.configInstructions.claudeCode.title'),
description: t('mcpSettings.configInstructions.claudeCode.description'),
snippet: JSON.stringify(
{
mcpServers: {
'memento-note': {
command: 'node',
args: ['path/to/mcp-server/index.js'],
env: {
DATABASE_URL: 'file:path/to/memento-note/prisma/dev.db',
APP_BASE_URL: 'http://localhost:3000',
},
},
},
},
null,
2
),
},
{
id: 'cursor',
title: t('mcpSettings.configInstructions.cursor.title'),
description: t('mcpSettings.configInstructions.cursor.description'),
snippet: JSON.stringify(
{
mcpServers: {
'memento-note': {
url: baseUrl + '/mcp',
headers: {
'x-api-key': 'YOUR_API_KEY',
},
},
},
},
null,
2
),
},
{
id: 'n8n',
title: t('mcpSettings.configInstructions.n8n.title'),
description: t('mcpSettings.configInstructions.n8n.description'),
snippet: `MCP Server URL: ${baseUrl}/mcp
Header: x-api-key: YOUR_API_KEY
Transport: Streamable HTTP`,
},
]
return (
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<ExternalLink className="h-5 w-5 shrink-0" />
<div>
<h2 className="text-lg font-semibold">
{t('mcpSettings.configInstructions.title')}
</h2>
<p className="text-sm text-gray-500">
{t('mcpSettings.configInstructions.description')}
</p>
</div>
</div>
<div className="space-y-2">
{configs.map(cfg => (
<div key={cfg.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-900 transition-colors"
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
>
<span className="font-medium text-sm">{cfg.title}</span>
{expanded === cfg.id ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{expanded === cfg.id && (
<div className="px-4 pb-4">
<p className="text-sm text-gray-500 mb-2">{cfg.description}</p>
<pre className="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">
<code>{cfg.snippet}</code>
</pre>
</div>
)}
</div>
))}
</div>
</Card>
)
}

View File

@@ -0,0 +1,337 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useLanguage } from '@/lib/i18n/LanguageProvider'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Lightbulb, ThumbsUp, ThumbsDown, X, Sparkles, ArrowRight, GitMerge } from 'lucide-react'
import { toast } from 'sonner'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
import { createNote, updateNote } from '@/app/actions/notes'
import { Note } from '@/lib/types'
interface MemoryEchoInsight {
id: string
note1Id: string
note2Id: string
note1: {
id: string
title: string | null
content: string
}
note2: {
id: string
title: string | null
content: string
}
similarityScore: number
insight: string
insightDate: Date
viewed: boolean
feedback: string | null
}
interface MemoryEchoNotificationProps {
onOpenNote?: (noteId: string) => void
}
export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationProps) {
const { t } = useLanguage()
const [insight, setInsight] = useState<MemoryEchoInsight | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [isDismissed, setIsDismissed] = useState(false)
const [showComparison, setShowComparison] = useState(false)
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [demoMode, setDemoMode] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Fetch insight on mount
useEffect(() => {
fetchInsight()
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [])
const fetchInsight = async () => {
setIsLoading(true)
try {
const res = await fetch('/api/ai/echo')
const data = await res.json()
if (data.insight) {
setInsight(data.insight)
// If we got an insight, check if user is in demo mode
setDemoMode(true)
}
} catch (error) {
console.error('[MemoryEcho] Failed to fetch insight:', error)
} finally {
setIsLoading(false)
}
}
// Start polling in demo mode after first dismiss
useEffect(() => {
if (isDismissed && demoMode && !pollingRef.current) {
pollingRef.current = setInterval(async () => {
try {
const res = await fetch('/api/ai/echo')
const data = await res.json()
if (data.insight) {
setInsight(data.insight)
setIsDismissed(false)
}
} catch {
// silent
}
}, 15000) // Poll every 15s
}
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
}
}, [isDismissed, demoMode])
const handleView = async () => {
if (!insight) return
try {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'view', insightId: insight.id })
})
toast.success(t('toast.openingConnection'))
setShowComparison(true)
} catch (error) {
console.error('[MemoryEcho] Failed to view connection:', error)
toast.error(t('toast.openConnectionFailed'))
}
}
const handleFeedback = async (feedback: 'thumbs_up' | 'thumbs_down') => {
if (!insight) return
try {
await fetch('/api/ai/echo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'feedback', insightId: insight.id, feedback })
})
if (feedback === 'thumbs_up') {
toast.success(t('toast.thanksFeedback'))
} else {
toast.success(t('toast.thanksFeedbackImproving'))
}
setIsDismissed(true)
// Stop polling after explicit feedback
if (pollingRef.current) {
clearInterval(pollingRef.current)
pollingRef.current = null
}
} catch (error) {
console.error('[MemoryEcho] Failed to submit feedback:', error)
toast.error(t('toast.feedbackFailed'))
}
}
const handleMergeNotes = async (noteIds: string[]) => {
if (!insight) return
const fetched = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
}
const handleDismiss = () => {
setIsDismissed(true)
}
if (isLoading || !insight) {
return null
}
const note1Title = insight.note1.title || t('notification.untitled')
const note2Title = insight.note2.title || t('notification.untitled')
const similarityPercentage = Math.round(insight.similarityScore * 100)
const comparisonNotes: Array<Partial<Note>> = [
{ id: insight.note1.id, title: insight.note1.title, content: insight.note1.content },
{ id: insight.note2.id, title: insight.note2.title, content: insight.note2.content }
]
return (
<>
{/* Comparison Modal */}
<ComparisonModal
isOpen={showComparison}
onClose={() => {
setShowComparison(false)
setIsDismissed(true)
}}
notes={comparisonNotes}
similarity={insight.similarityScore}
onOpenNote={(noteId) => {
setShowComparison(false)
setIsDismissed(true)
onOpenNote?.(noteId)
}}
onMergeNotes={handleMergeNotes}
/>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
setIsDismissed(true)
}}
/>
)}
{/* Notification Card — hidden when dismissed or modal open */}
{!isDismissed && (
<div className="fixed bottom-4 right-4 z-50 max-w-md w-full animate-in slide-in-from-bottom-4 fade-in duration-500">
<Card className="border-amber-200 dark:border-amber-900 shadow-lg bg-gradient-to-br from-amber-50 to-white dark:from-amber-950/20 dark:to-background">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<Lightbulb className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<CardTitle className="text-base flex items-center gap-2">
{t('memoryEcho.title')}
<Sparkles className="h-4 w-4 text-amber-500" />
</CardTitle>
<CardDescription className="text-xs mt-1">
{t('memoryEcho.description')}
</CardDescription>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -mr-2 -mt-2"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* AI-generated insight */}
<div className="bg-white dark:bg-zinc-900 rounded-lg p-3 border border-amber-100 dark:border-amber-900/30">
<p className="text-sm text-gray-700 dark:text-gray-300">
{insight.insight}
</p>
</div>
{/* Connected notes */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="border-primary/20 text-primary dark:border-primary/30 dark:text-primary-foreground">
{note1Title}
</Badge>
<ArrowRight className="h-3 w-3 text-gray-400" />
<Badge variant="outline" className="border-purple-200 text-purple-700 dark:border-purple-900 dark:text-purple-300">
{note2Title}
</Badge>
<Badge variant="secondary" className="ml-auto text-xs">
{t('memoryEcho.match', { percentage: similarityPercentage })}
</Badge>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
className="flex-1 bg-amber-600 hover:bg-amber-700 text-white"
onClick={handleView}
>
{t('memoryEcho.viewConnection')}
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 border-purple-200 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/20"
onClick={() => handleMergeNotes([insight.note1.id, insight.note2.id])}
>
<GitMerge className="h-4 w-4 mr-1" />
{t('memoryEcho.editorSection.merge')}
</Button>
<div className="flex items-center gap-1 border-l pl-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
onClick={() => handleFeedback('thumbs_up')}
title={t('memoryEcho.helpful')}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleFeedback('thumbs_down')}
title={t('memoryEcho.notHelpful')}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</div>
{/* Dismiss link */}
<button
className="w-full text-center text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 py-1"
onClick={handleDismiss}
>
{t('memoryEcho.dismiss')}
</button>
</CardContent>
</Card>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,229 @@
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Archive,
ArchiveRestore,
MoreVertical,
Palette,
Pin,
Users,
Maximize2,
FileText,
Trash2,
RotateCcw,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
import { useLanguage } from "@/lib/i18n"
interface NoteActionsProps {
isPinned: boolean
isArchived: boolean
currentColor: string
currentSize?: 'small' | 'medium' | 'large'
onTogglePin: () => void
onToggleArchive: () => void
onColorChange: (color: string) => void
onSizeChange?: (size: 'small' | 'medium' | 'large') => void
onDelete: () => void
onShareCollaborators?: () => void
isMarkdown?: boolean
onToggleMarkdown?: () => void
isTrashView?: boolean
onRestore?: () => void
onPermanentDelete?: () => void
className?: string
}
export function NoteActions({
isPinned,
isArchived,
currentColor,
currentSize = 'small',
onTogglePin,
onToggleArchive,
onColorChange,
onSizeChange,
onDelete,
onShareCollaborators,
isMarkdown = false,
onToggleMarkdown,
isTrashView,
onRestore,
onPermanentDelete,
className
}: NoteActionsProps) {
const { t } = useLanguage()
// Trash view: show only Restore and Permanent Delete
if (isTrashView) {
return (
<div
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Restore Button */}
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 px-2 text-xs"
onClick={onRestore}
title={t('trash.restore')}
>
<RotateCcw className="h-4 w-4" />
<span className="hidden sm:inline">{t('trash.restore')}</span>
</Button>
{/* Permanent Delete Button */}
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 px-2 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
onClick={onPermanentDelete}
title={t('trash.permanentDelete')}
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t('trash.permanentDelete')}</span>
</Button>
</div>
)
}
return (
<div
className={cn("flex items-center justify-end gap-1", className)}
onClick={(e) => e.stopPropagation()}
>
{/* Color Palette */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([colorName, classes]) => (
<button
key={colorName}
className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg,
currentColor === colorName ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => onColorChange(colorName)}
title={colorName}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Markdown Toggle */}
{onToggleMarkdown && (
<Button
variant="ghost"
size="sm"
className={cn("h-8 gap-1 px-2 text-xs", isMarkdown && "text-primary bg-primary/10")}
title="Markdown"
onClick={onToggleMarkdown}
>
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">MD</span>
</Button>
)}
{/* More Options */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label={t('notes.moreOptions')}>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Pin/Unpin Option */}
<DropdownMenuItem onClick={onTogglePin}>
{isPinned ? (
<>
<Pin className="h-4 w-4 mr-2" />
{t('notes.unpin')}
</>
) : (
<>
<Pin className="h-4 w-4 mr-2" />
{t('notes.pin')}
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleArchive}>
{isArchived ? (
<>
<ArchiveRestore className="h-4 w-4 mr-2" />
{t('notes.unarchive')}
</>
) : (
<>
<Archive className="h-4 w-4 mr-2" />
{t('notes.archive')}
</>
)}
</DropdownMenuItem>
{/* Size Selector */}
{onSizeChange && (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{t('notes.size')}
</div>
{(['small', 'medium', 'large'] as const).map((size) => (
<DropdownMenuItem
key={size}
onSelect={(e) => {
onSizeChange?.(size);
}}
className={cn(
"capitalize",
currentSize === size && "bg-accent"
)}
>
<Maximize2 className="h-4 w-4 mr-2" />
{t(`notes.${size}` as const)}
</DropdownMenuItem>
))}
</>
)}
{/* Collaborators */}
{onShareCollaborators && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onShareCollaborators()
}}
>
<Users className="h-4 w-4 mr-2" />
{t('notes.shareWithCollaborators')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -0,0 +1,737 @@
'use client'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, createNote } from '@/app/actions/notes'
import { cn } from '@/lib/utils'
import { formatDistanceToNow, Locale } from 'date-fns'
import { enUS } from 'date-fns/locale/en-US'
import { fr } from 'date-fns/locale/fr'
import { es } from 'date-fns/locale/es'
import { de } from 'date-fns/locale/de'
import { faIR } from 'date-fns/locale/fa-IR'
import { it } from 'date-fns/locale/it'
import { pt } from 'date-fns/locale/pt'
import { ru } from 'date-fns/locale/ru'
import { zhCN } from 'date-fns/locale/zh-CN'
import { ja } from 'date-fns/locale/ja'
import { ko } from 'date-fns/locale/ko'
import { ar } from 'date-fns/locale/ar'
import { hi } from 'date-fns/locale/hi'
import { nl } from 'date-fns/locale/nl'
import { pl } from 'date-fns/locale/pl'
import { MarkdownContent } from './markdown-content'
import { LabelBadge } from './label-badge'
import { NoteImages } from './note-images'
import { NoteChecklist } from './note-checklist'
import { NoteActions } from './note-actions'
import { CollaboratorDialog } from './collaborator-dialog'
import { CollaboratorAvatars } from './collaborator-avatars'
import { ConnectionsBadge } from './connections-badge'
import { ConnectionsOverlay } from './connections-overlay'
import { ComparisonModal } from './comparison-modal'
import { FusionModal } from './fusion-modal'
import { useConnectionsCompare } from '@/hooks/use-connections-compare'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
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> = {
en: enUS,
fr: fr,
es: es,
de: de,
fa: faIR,
it: it,
pt: pt,
ru: ru,
zh: zhCN,
ja: ja,
ko: ko,
ar: ar,
hi: hi,
nl: nl,
pl: pl,
}
function getDateLocale(language: string): Locale {
return localeMap[language] || enUS
}
// Map icon names to lucide-react components
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
'briefcase': Briefcase,
'document': FileText,
'lightning': Zap,
'chart': BarChart3,
'globe': Globe,
'sparkle': Sparkles,
'book': Book,
'heart': Heart,
'crown': Crown,
'music': Music,
'building': Building2,
}
// Function to get icon component by name
function getNotebookIcon(iconName: string): LucideIcon {
const IconComponent = ICON_MAP[iconName] || Folder
return IconComponent
}
interface NoteCardProps {
note: Note
onEdit?: (note: Note, readOnly?: boolean) => void
isDragging?: boolean
isDragOver?: boolean
onDragStart?: (noteId: string) => void
onDragEnd?: () => void
onResize?: () => void
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
isTrashView?: boolean
}
// Helper function to get initials from name
function getInitials(name: string): string {
if (!name) return '??'
const trimmedName = name.trim()
const parts = trimmedName.split(' ')
if (parts.length === 1) {
return trimmedName.substring(0, 2).toUpperCase()
}
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
// Helper function to get avatar color based on name hash
function getAvatarColor(name: string): string {
const colors = [
'bg-primary',
'bg-purple-500',
'bg-green-500',
'bg-orange-500',
'bg-pink-500',
'bg-teal-500',
'bg-red-500',
'bg-indigo-500',
]
const hash = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[hash % colors.length]
}
export const NoteCard = memo(function NoteCard({
note,
onEdit,
onDragStart,
onDragEnd,
isDragging,
onResize,
onSizeChange,
isTrashView
}: NoteCardProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { refreshLabels } = useLabels()
const { triggerRefresh } = useNoteRefresh()
const { data: session } = useSession()
const { t, language } = useLanguage()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const [, startTransition] = useTransition()
const [isDeleting, setIsDeleting] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
const [collaborators, setCollaborators] = useState<any[]>([])
const [owner, setOwner] = useState<any>(null)
const [showConnectionsOverlay, setShowConnectionsOverlay] = useState(false)
const [comparisonNotes, setComparisonNotes] = useState<string[] | null>(null)
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
// Move note to a notebook
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
}
// Optimistic UI state for instant feedback
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
// Local color state so color persists after transition ends
const [localColor, setLocalColor] = useState(note.color)
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
// Check if this note is currently open in the editor
const isNoteOpenInEditor = searchParams.get('note') === note.id
// Only fetch comparison notes when we have IDs to compare
const { notes: comparisonNotesData, isLoading: isLoadingComparison } = useConnectionsCompare(
comparisonNotes && comparisonNotes.length > 0 ? comparisonNotes : null
)
const currentUserId = session?.user?.id
const canManageCollaborators = currentUserId && note.userId && currentUserId === note.userId
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
const isOwner = currentUserId && note.userId && currentUserId === note.userId
// Load collaborators only for shared notes (not owned by current user)
useEffect(() => {
// Skip API call for notes owned by current user — no need to fetch collaborators
if (!isSharedNote) {
// For own notes, set owner to current user
if (currentUserId && session?.user) {
setOwner({
id: currentUserId,
name: session.user.name,
email: session.user.email,
image: session.user.image,
})
}
return
}
let isMounted = true
const loadCollaborators = async () => {
if (note.userId && isMounted) {
try {
const users = await getNoteAllUsers(note.id)
if (isMounted) {
setCollaborators(users)
if (users.length > 0) {
setOwner(users[0])
}
}
} catch (error) {
console.error('Failed to load collaborators:', error)
if (isMounted) {
setCollaborators([])
}
}
}
}
loadCollaborators()
return () => {
isMounted = false
}
}, [note.id, note.userId, isSharedNote, currentUserId, session?.user])
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteNote(note.id)
await refreshLabels()
} catch (error) {
console.error('Failed to delete note:', error)
setIsDeleting(false)
}
}
const handleTogglePin = async () => {
startTransition(async () => {
addOptimisticNote({ isPinned: !note.isPinned })
await togglePin(note.id, !note.isPinned)
if (!note.isPinned) {
toast.success(t('notes.pinned') || 'Note pinned')
} else {
toast.info(t('notes.unpinned') || 'Note unpinned')
}
})
}
const handleToggleArchive = async () => {
startTransition(async () => {
addOptimisticNote({ isArchived: !note.isArchived })
await toggleArchive(note.id, !note.isArchived)
})
}
const handleColorChange = async (color: string) => {
setLocalColor(color) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ color })
await updateNote(note.id, { color }, { skipRevalidation: false })
})
}
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
startTransition(async () => {
// Instant visual feedback for the card itself
addOptimisticNote({ size })
// Notify parent so it can update its local state
onSizeChange?.(size)
// Trigger layout refresh
onResize?.()
setTimeout(() => onResize?.(), 300)
// Update server in background
try {
await updateSize(note.id, size);
} catch (error) {
console.error('Failed to update note size:', error);
}
})
}
const handleCheckItem = async (checkItemId: string) => {
if (note.type === 'checklist' && Array.isArray(note.checkItems)) {
const updatedItems = note.checkItems.map(item =>
item.id === checkItemId ? { ...item, checked: !item.checked } : item
)
startTransition(async () => {
addOptimisticNote({ checkItems: updatedItems })
await updateNote(note.id, { checkItems: updatedItems })
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
})
}
}
const handleLeaveShare = async () => {
if (confirm(t('notes.confirmLeaveShare'))) {
try {
await leaveSharedNote(note.id)
setIsDeleting(true) // Hide the note from view
} catch (error) {
console.error('Failed to leave share:', error)
}
}
}
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
e.stopPropagation() // Prevent opening the note editor
startTransition(async () => {
addOptimisticNote({ autoGenerated: null })
await removeFusedBadge(note.id)
// No router.refresh() — optimistic update is sufficient and avoids grid rebuild
})
}
if (isDeleting) return null
const getMinHeight = (size?: string) => {
switch (size) {
case 'medium': return '350px'
case 'large': return '500px'
default: return '150px' // small
}
}
return (
<Card
data-testid="note-card"
data-draggable="true"
data-note-id={note.id}
data-size={optimisticNote.size}
style={{ minHeight: getMinHeight(optimisticNote.size) }}
draggable={true}
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', note.id)
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
onDragStart?.(note.id)
}}
onDragEnd={() => onDragEnd?.()}
className={cn(
'note-card group relative rounded-2xl overflow-hidden p-5 border shadow-sm',
'transition-all duration-200 ease-out',
'hover:shadow-xl hover:-translate-y-1',
colorClasses.bg,
colorClasses.card,
colorClasses.hover,
colorClasses.hover,
isDragging && 'shadow-2xl' // Removed opacity, scale, and rotation for clean drag
)}
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('.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}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 bg-primary/10 dark:bg-primary/20 hover:bg-primary/20 dark:hover:bg-primary/30 text-primary dark:text-primary-foreground"
title={t('notebookSuggestion.moveToNotebook')}
>
<FolderOpen className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{t('notebookSuggestion.moveToNotebook')}
</div>
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
<StickyNote className="h-4 w-4 mr-2" />
{t('notebookSuggestion.generalNotes')}
</DropdownMenuItem>
{notebooks.map((notebook: any) => {
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<DropdownMenuItem
key={notebook.id}
onClick={() => handleMoveToNotebook(notebook.id)}
>
<NotebookIcon className="h-4 w-4 mr-2" />
{notebook.name}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Pin Button - Visible on hover or if pinned */}
<Button
variant="ghost"
size="sm"
data-testid="pin-button"
className={cn(
"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) => {
e.stopPropagation()
handleTogglePin()
}}
>
<Pin
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-primary"
/>
)}
{/* Fusion Badge */}
{optimisticNote.aiProvider === 'fusion' && (
<div className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 border border-purple-200 dark:border-purple-800 flex items-center gap-1 group/badge relative mb-2 w-fit">
<Link2 className="h-2.5 w-2.5" />
{t('memoryEcho.fused')}
<button
onClick={handleRemoveFusedBadge}
className="ml-1 opacity-0 group-hover/badge:opacity-100 hover:opacity-100 transition-opacity"
title={t('notes.remove') || 'Remove'}
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</div>
)}
{/* Title */}
{optimisticNote.title && (
<h3 className="text-base font-medium mb-2 pr-10 text-foreground">
{optimisticNote.title}
</h3>
)}
{/* Search Match Type Badge */}
{optimisticNote.matchType && (
<Badge
variant={optimisticNote.matchType === 'exact' ? 'default' : 'secondary'}
className={cn(
'mb-2 text-xs',
optimisticNote.matchType === 'exact'
? 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-300 dark:border-green-800'
: 'bg-primary/10 text-primary border-primary/20 dark:bg-primary/20 dark:text-primary-foreground'
)}
>
{t(`semanticSearch.${optimisticNote.matchType === 'exact' ? 'exactMatch' : 'related'}`)}
</Badge>
)}
{/* Shared badge */}
{isSharedNote && owner && (
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-primary dark:text-primary-foreground font-medium">
{t('notes.sharedBy')} {owner.name || owner.email}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-gray-500 hover:text-red-600 dark:hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
handleLeaveShare()
}}
>
<LogOut className="h-3 w-3 mr-1" />
{t('notes.leaveShare')}
</Button>
</div>
)}
{/* Images Component */}
<NoteImages images={optimisticNote.images || []} title={optimisticNote.title} />
{/* Link Previews */}
{Array.isArray(optimisticNote.links) && optimisticNote.links.length > 0 && (
<div className="flex flex-col gap-2 mb-2">
{optimisticNote.links.map((link, idx) => (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block border rounded-md overflow-hidden bg-white/50 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/40 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{link.imageUrl && (
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="p-2">
<h4 className="font-medium text-xs truncate text-gray-900 dark:text-gray-100">{link.title || link.url}</h4>
{link.description && <p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">{link.description}</p>}
<span className="text-[10px] text-primary mt-1 block">
{new URL(link.url).hostname}
</span>
</div>
</a>
))}
</div>
)}
{/* Content */}
{optimisticNote.type === 'text' ? (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent content={optimisticNote.content} />
</div>
) : (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels - using shared LabelBadge component */}
{optimisticNote.notebookId && Array.isArray(optimisticNote.labels) && optimisticNote.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{optimisticNote.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}
{/* Footer with Date only */}
<div className="mt-3 flex items-center justify-end">
{/* Creation Date */}
<div className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: getDateLocale(language) })}
</div>
</div>
{/* Owner Avatar - Aligned with action buttons at bottom */}
{owner && (
<div
className={cn(
"absolute bottom-2 left-2 z-20",
"w-6 h-6 rounded-full text-white text-[10px] font-semibold flex items-center justify-center",
getAvatarColor(owner.name || owner.email || 'Unknown')
)}
title={owner.name || owner.email || 'Unknown'}
>
{getInitials(owner.name || owner.email || '??')}
</div>
)}
{/* Action Bar Component - Always show for now to fix regression */}
{true && (
<NoteActions
isPinned={optimisticNote.isPinned}
isArchived={optimisticNote.isArchived}
currentColor={optimisticNote.color}
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
onTogglePin={handleTogglePin}
onToggleArchive={handleToggleArchive}
onColorChange={handleColorChange}
onSizeChange={handleSizeChange}
onDelete={() => setShowDeleteDialog(true)}
onShareCollaborators={() => setShowCollaboratorDialog(true)}
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
/>
)}
{/* Collaborator Dialog */}
{currentUserId && note.userId && (
<div onClick={(e) => e.stopPropagation()}>
<CollaboratorDialog
open={showCollaboratorDialog}
onOpenChange={setShowCollaboratorDialog}
noteId={note.id}
noteOwnerId={note.userId}
currentUserId={currentUserId}
/>
</div>
)}
{/* Connections Badge - Bottom right (spec: amber, absolute) */}
<div className="absolute bottom-2 right-2 z-10">
<ConnectionsBadge
noteId={note.id}
onClick={() => {
if (!isNoteOpenInEditor) {
setShowConnectionsOverlay(true)
}
}}
/>
</div>
{/* Connections Overlay */}
<div onClick={(e) => e.stopPropagation()}>
<ConnectionsOverlay
isOpen={showConnectionsOverlay}
onClose={() => setShowConnectionsOverlay(false)}
noteId={note.id}
onOpenNote={(connNoteId) => {
setShowConnectionsOverlay(false)
const params = new URLSearchParams(searchParams.toString())
params.set('note', connNoteId)
router.push(`?${params.toString()}`)
}}
onCompareNotes={(noteIds) => {
setComparisonNotes(noteIds)
}}
onMergeNotes={async (noteIds) => {
const fetchedNotes = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
setFusionNotes(fetchedNotes.filter((n: any) => n !== null) as Array<Partial<Note>>)
}}
/>
</div>
{/* Comparison Modal */}
{comparisonNotes && comparisonNotesData.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<ComparisonModal
isOpen={!!comparisonNotes}
onClose={() => setComparisonNotes(null)}
notes={comparisonNotesData}
onOpenNote={(noteId) => {
const foundNote = comparisonNotesData.find(n => n.id === noteId)
if (foundNote) {
onEdit?.(foundNote, false)
}
}}
/>
</div>
)}
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<div onClick={(e) => e.stopPropagation()}>
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={async ({ title, content }, options) => {
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
triggerRefresh()
}}
/>
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
<AlertDialogDescription>
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleDelete}>
{t('notes.delete') || 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
)
})

View File

@@ -0,0 +1,42 @@
import { CheckItem } from "@/lib/types"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
interface NoteChecklistProps {
items: CheckItem[]
onToggleItem: (itemId: string) => void
}
export function NoteChecklist({ items, onToggleItem }: NoteChecklistProps) {
if (!items || items.length === 0) return null
return (
<div className="space-y-1">
{items.map((item) => (
<div
key={item.id}
className="flex items-start gap-2"
onClick={(e) => {
e.stopPropagation()
onToggleItem(item.id)
}}
>
<Checkbox
checked={item.checked}
className="mt-0.5"
/>
<span
className={cn(
'text-sm',
item.checked
? 'line-through text-gray-500 dark:text-gray-500'
: 'text-gray-700 dark:text-gray-300'
)}
>
{item.text}
</span>
</div>
))}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import { cn, asArray } from "@/lib/utils"
interface NoteImagesProps {
images: string[]
title?: string | null
}
export function NoteImages({ images: rawImages, title }: NoteImagesProps) {
const images = asArray<string>(rawImages)
if (images.length === 0) return null
return (
<div className={cn(
"mb-3 -mx-4",
!title && "-mt-4"
)}>
{images.length === 1 ? (
<img
src={images[0]}
alt=""
className="w-full h-auto rounded-lg"
/>
) : images.length === 2 ? (
<div className="grid grid-cols-2 gap-2 px-4">
{images.map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
</div>
) : images.length === 3 ? (
<div className="grid grid-cols-2 gap-2 px-4">
<img
src={images[0]}
alt=""
className="col-span-2 w-full h-auto rounded-lg"
/>
{images.slice(1).map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
</div>
) : (
<div className="grid grid-cols-2 gap-2 px-4">
{images.slice(0, 4).map((img, idx) => (
<img
key={idx}
src={img}
alt=""
className="w-full h-auto rounded-lg"
/>
))}
{images.length > 4 && (
<div className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs">
+{images.length - 4}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,977 @@
'use client'
import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { LabelBadge } from '@/components/label-badge'
import { EditorConnectionsSection } from '@/components/editor-connections-section'
import { FusionModal } from '@/components/fusion-modal'
import { ComparisonModal } from '@/components/comparison-modal'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
updateNote,
togglePin,
toggleArchive,
updateColor,
deleteNote,
createNote,
} from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import {
Pin,
Palette,
Archive,
ArchiveRestore,
Trash2,
ImageIcon,
Link as LinkIcon,
X,
Plus,
CheckSquare,
FileText,
Eye,
Sparkles,
Loader2,
Check,
Wand2,
AlignLeft,
Minimize2,
Lightbulb,
RotateCcw,
Languages,
ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { MarkdownContent } from '@/components/markdown-content'
import { EditorImages } from '@/components/editor-images'
import { useAutoTagging } from '@/hooks/use-auto-tagging'
import { GhostTags } from '@/components/ghost-tags'
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
import { TitleSuggestions } from '@/components/title-suggestions'
import { useLabels } from '@/context/LabelContext'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NoteInlineEditorProps {
note: Note
onDelete?: (noteId: string) => void
onArchive?: (noteId: string) => void
onChange?: (noteId: string, fields: Partial<Note>) => void
colorKey: NoteColor
/** If true and the note is a Markdown note, open directly in preview mode */
defaultPreviewMode?: boolean
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
async function saveInline(
id: string,
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean }
) {
await fetch(`/api/notes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
}
export function NoteInlineEditor({
note,
onDelete,
onArchive,
onChange,
colorKey,
defaultPreviewMode = false,
}: NoteInlineEditorProps) {
const { t, language } = useLanguage()
const { labels: globalLabels, addLabel } = useLabels()
const [, startTransition] = useTransition()
const { triggerRefresh } = useNoteRefresh()
// ── Local edit state ──────────────────────────────────────────────────────
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '')
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false)
)
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
// Link dialog
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const [isAddingLink, setIsAddingLink] = useState(false)
// AI popover
const [aiOpen, setAiOpen] = useState(false)
const [isProcessingAI, setIsProcessingAI] = useState(false)
// Undo after AI: saves content before transformation
const [previousContent, setPreviousContent] = useState<string | null>(null)
// Translate sub-panel
const [showTranslate, setShowTranslate] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
const noteIdRef = useRef(note.id)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '',
enabled: note.type === 'text' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown }
}, [title, content, checkItems, isMarkdown])
// ── Sync when selected note switches ─────────────────────────────────────
useEffect(() => {
// Flush unsaved changes for the PREVIOUS note before switching
if (isDirty && noteIdRef.current !== note.id) {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
noteIdRef.current = note.id
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
setIsDirty(false)
setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [note.id])
// ── Auto-save (1.5 s debounce, skipContentTimestamp) ─────────────────────
const scheduleSave = useCallback(() => {
setIsDirty(true)
clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
setIsSaving(true)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
})
setIsDirty(false)
} catch {
// silent — retry on next keystroke
} finally {
setIsSaving(false)
}
}, 1500)
}, [note.type])
// Flush on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text',
})
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(
(s) => s?.tag && !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
)
const handleSelectGhostTag = async (tag: string) => {
const exists = (note.labels || []).some((l) => l.toLowerCase() === tag.toLowerCase())
if (!exists) {
const newLabels = [...(note.labels || []), tag]
// Optimistic UI — update sidebar immediately, no page refresh needed
onChange?.(note.id, { labels: newLabels })
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
try { await addLabel(tag) } catch {}
}
toast.success(t('ai.tagAdded', { tag }))
}
}
const fetchNotesByIds = async (noteIds: string[]) => {
const fetched = await Promise.all(noteIds.map(async (id) => {
try {
const res = await fetch(`/api/notes/${id}`)
if (!res.ok) return null
const data = await res.json()
return data.success && data.data ? data.data : null
} catch { return null }
}))
return fetched.filter((n: any) => n !== null) as Array<Partial<Note>>
}
const handleMergeNotes = async (noteIds: string[]) => {
setFusionNotes(await fetchNotesByIds(noteIds))
}
const handleCompareNotes = async (noteIds: string[]) => {
setComparisonNotes(await fetchNotesByIds(noteIds))
}
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
await createNote({
title,
content,
labels: options.keepAllTags
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
notebookId: fusionNotes[0].notebookId ?? undefined
})
if (options.archiveOriginals) {
for (const n of fusionNotes) {
if (n.id) await updateNote(n.id, { isArchived: true })
}
}
toast.success(t('toast.notesFusionSuccess'))
setFusionNotes([])
triggerRefresh()
}
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
const handleTogglePin = () => {
startTransition(async () => {
// Optimitistic update
onChange?.(note.id, { isPinned: !note.isPinned })
// Call with skipRevalidation to avoid server layout refresh interfering with optimistic state
await updateNote(note.id, { isPinned: !note.isPinned }, { skipRevalidation: true })
toast.success(note.isPinned ? t('notes.unpinned') || 'Désépinglée' : t('notes.pinned') || 'Épinglée')
})
}
const handleToggleArchive = () => {
startTransition(async () => {
onArchive?.(note.id)
await updateNote(note.id, { isArchived: !note.isArchived }, { skipRevalidation: true })
})
}
const handleColorChange = (color: string) => {
startTransition(async () => {
onChange?.(note.id, { color })
await updateNote(note.id, { color }, { skipRevalidation: true })
})
}
const handleDelete = () => {
if (!confirm(t('notes.confirmDelete'))) return
startTransition(async () => {
await deleteNote(note.id)
onDelete?.(note.id)
})
}
// ── Image upload ──────────────────────────────────────────────────────────
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files) return
for (const file of Array.from(files)) {
const formData = new FormData()
formData.append('file', file)
try {
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) throw new Error('Upload failed')
const data = await res.json()
const newImages = [...(note.images || []), data.url]
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
} catch {
toast.error(t('notes.uploadFailed', { filename: file.name }))
}
}
if (fileInputRef.current) fileInputRef.current.value = ''
}
const handleRemoveImage = async (index: number) => {
const newImages = (note.images || []).filter((_, i) => i !== index)
onChange?.(note.id, { images: newImages })
await updateNote(note.id, { images: newImages })
}
// ── Link ──────────────────────────────────────────────────────────────────
const handleAddLink = async () => {
if (!linkUrl) return
setIsAddingLink(true)
try {
const metadata = await fetchLinkMetadata(linkUrl)
const newLink = metadata || { url: linkUrl, title: linkUrl }
const newLinks = [...(note.links || []), newLink]
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
toast.success(t('notes.linkAdded'))
} catch {
toast.error(t('notes.linkAddFailed'))
} finally {
setLinkUrl('')
setShowLinkInput(false)
setIsAddingLink(false)
}
}
const handleRemoveLink = async (index: number) => {
const newLinks = (note.links || []).filter((_, i) => i !== index)
onChange?.(note.id, { links: newLinks })
await updateNote(note.id, { links: newLinks })
}
// ── AI actions (called from Popover in toolbar) ───────────────────────────
const callAI = async (option: 'clarify' | 'shorten' | 'improve') => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) {
toast.error(t('ai.reformulationMinWords', { count: wc }))
return
}
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content) // save for undo
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, option }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Failed to reformulate')
changeContent(data.reformulatedText || data.text)
scheduleSave()
toast.success(t('ai.reformulationApplied'))
} catch {
toast.error(t('ai.reformulationFailed'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const callTranslate = async (targetLanguage: string) => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 3) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content, targetLanguage }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Translation failed')
changeContent(data.translatedText)
scheduleSave()
toast.success(t('ai.translationApplied') || `Traduit en ${targetLanguage}`)
} catch {
toast.error(t('ai.translationFailed') || 'Traduction échouée')
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
const handleTransformMarkdown = async () => {
const wc = content.split(/\s+/).filter(Boolean).length
if (!content || wc < 10) { toast.error(t('ai.reformulationMinWords', { count: wc })); return }
setAiOpen(false)
setShowTranslate(false)
setPreviousContent(content)
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/transform-markdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: content }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error)
changeContent(data.transformedText)
setIsMarkdown(true)
scheduleSave()
toast.success(t('ai.transformSuccess'))
} catch {
toast.error(t('ai.transformError'))
setPreviousContent(null)
} finally {
setIsProcessingAI(false)
}
}
// ── Checklist helpers ─────────────────────────────────────────────────────
const handleToggleCheckItem = (id: string) => {
const updated = checkItems.map((ci) =>
ci.id === id ? { ...ci, checked: !ci.checked } : ci
)
setCheckItems(updated)
scheduleSave()
}
const handleUpdateCheckText = (id: string, text: string) => {
const updated = checkItems.map((ci) => (ci.id === id ? { ...ci, text } : ci))
setCheckItems(updated)
scheduleSave()
}
const handleAddCheckItem = () => {
const updated = [...checkItems, { id: Date.now().toString(), text: '', checked: false }]
setCheckItems(updated)
scheduleSave()
}
const handleRemoveCheckItem = (id: string) => {
const updated = checkItems.filter((ci) => ci.id !== id)
setCheckItems(updated)
scheduleSave()
}
const dateLocale = getDateLocale(language)
return (
<div className="flex h-full flex-col overflow-hidden">
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
<div className="flex items-center gap-1">
{/* Image upload */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addImage') || 'Ajouter une image'}
onClick={() => fileInputRef.current?.click()}
>
<ImageIcon className="h-4 w-4" />
</Button>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleImageUpload} />
{/* Link */}
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0"
title={t('notes.addLink') || 'Ajouter un lien'}
onClick={() => setShowLinkInput(!showLinkInput)}
>
<LinkIcon className="h-4 w-4" />
</Button>
{/* Markdown toggle */}
<Button
variant="ghost" size="sm"
className={cn('h-8 gap-1 px-2 text-xs', isMarkdown && 'text-primary')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false); scheduleSave() }}
title="Markdown"
>
<FileText className="h-3.5 w-3.5" />
<span className="hidden sm:inline">MD</span>
</Button>
{isMarkdown && (
<Button
variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
>
<Eye className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{showMarkdownPreview ? t('notes.edit') || 'Éditer' : t('notes.preview') || 'Aperçu'}</span>
</Button>
)}
{/* ── AI Popover (in toolbar, non-intrusive) ─────────────────────── */}
{note.type === 'text' && (
<Popover open={aiOpen} onOpenChange={(o) => { setAiOpen(o); if (!o) setShowTranslate(false) }}>
<PopoverTrigger asChild>
<Button
variant="ghost" size="sm"
className={cn(
'h-8 gap-1.5 px-2 text-xs transition-colors',
isProcessingAI && 'text-primary',
aiOpen && 'bg-muted text-primary',
)}
disabled={isProcessingAI}
title="Assistant IA"
>
{isProcessingAI
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Sparkles className="h-3.5 w-3.5" />
}
<span className="hidden sm:inline">IA</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1">
{!showTranslate ? (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('clarify')}
>
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.clarify') || 'Clarifier'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.clarifyDesc') || 'Rendre plus clair'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('shorten')}
>
<Minimize2 className="h-4 w-4 text-blue-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.shorten') || 'Raccourcir'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.shortenDesc') || 'Version concise'}</p>
</div>
</button>
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={() => callAI('improve')}
>
<AlignLeft className="h-4 w-4 text-emerald-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.improve') || 'Améliorer'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.improveDesc') || 'Meilleure rédaction'}</p>
</div>
</button>
<button type="button"
className="flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left w-full"
onClick={() => setShowTranslate(true)}
>
<div className="flex items-center gap-2">
<Languages className="h-4 w-4 text-sky-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.translate') || 'Traduire'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.translateDesc') || 'Changer la langue'}</p>
</div>
</div>
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</button>
<div className="my-0.5 border-t border-border/40" />
<button type="button"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-muted text-left"
onClick={handleTransformMarkdown}
>
<Wand2 className="h-4 w-4 text-violet-500 shrink-0" />
<div>
<p className="font-medium">{t('ai.toMarkdown') || 'En Markdown'}</p>
<p className="text-[11px] text-muted-foreground">{t('ai.toMarkdownDesc') || 'Formater en MD'}</p>
</div>
</button>
</div>
) : (
<div className="flex flex-col gap-0.5">
<button type="button"
className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowTranslate(false)}
>
<RotateCcw className="h-3 w-3" />
{t('ai.translateBack') || 'Retour'}
</button>
<div className="my-0.5 border-t border-border/40" />
{[
{ code: 'French', label: 'Français 🇫🇷' },
{ code: 'English', label: 'English 🇬🇧' },
{ code: 'Persian', label: 'فارسی 🇮🇷' },
{ code: 'Spanish', label: 'Español 🇪🇸' },
{ code: 'German', label: 'Deutsch 🇩🇪' },
{ code: 'Italian', label: 'Italiano 🇮🇹' },
{ code: 'Portuguese', label: 'Português 🇵🇹' },
{ code: 'Arabic', label: 'العربية 🇸🇦' },
{ code: 'Chinese', label: '中文 🇨🇳' },
{ code: 'Japanese', label: '日本語 🇯🇵' },
].map(({ code, label }) => (
<button key={code} type="button"
className="w-full rounded-md px-3 py-1.5 text-sm hover:bg-muted text-left"
onClick={() => callTranslate(code)}
>
{label}
</button>
))}
</div>
)}
</PopoverContent>
</Popover>
)}
{/* ── Undo AI button ─────────────────────────────────────────────── */}
{previousContent !== null && (
<Button
variant="ghost" size="sm"
className="h-8 gap-1.5 px-2 text-xs text-amber-600 hover:text-amber-700 hover:bg-amber-50 dark:hover:bg-amber-950/30"
title={t('ai.undoAI') || 'Annuler transformation IA'}
onClick={() => {
changeContent(previousContent)
setPreviousContent(null)
scheduleSave()
toast.info(t('ai.undoApplied') || 'Texte original restauré')
}}
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t('ai.undo') || 'Annuler IA'}</span>
</Button>
)}
</div>
<div className="flex items-center gap-1">
{/* Save status indicator */}
<span className="mr-1 flex items-center gap-1 text-[11px] text-muted-foreground/50 select-none">
{isSaving ? (
<><Loader2 className="h-3 w-3 animate-spin" /> Sauvegarde</>
) : isDirty ? (
<><span className="h-1.5 w-1.5 rounded-full bg-amber-400" /> Modifié</>
) : (
<><Check className="h-3 w-3 text-emerald-500" /> Sauvegardé</>
)}
</span>
{/* Pin */}
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
</Button>
{/* Color picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.changeColor')}>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="grid grid-cols-5 gap-2 p-2">
{Object.entries(NOTE_COLORS).map(([name, cls]) => (
<button type="button"
key={name}
className={cn(
'h-7 w-7 rounded-full border-2 transition-transform hover:scale-110',
cls.bg,
note.color === name ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-700'
)}
onClick={() => handleColorChange(name)}
title={name}
/>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* More actions */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" title={t('notes.moreOptions')}>
<span className="text-base leading-none text-muted-foreground"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleToggleArchive}>
{note.isArchived
? <><ArchiveRestore className="h-4 w-4 mr-2" />{t('notes.unarchive')}</>
: <><Archive className="h-4 w-4 mr-2" />{t('notes.archive')}</>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600 dark:text-red-400" onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />{t('notes.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
{showLinkInput && (
<div className="flex shrink-0 items-center gap-2 border-b border-border/30 bg-muted/30 px-4 py-2">
<input
type="url"
className="flex-1 rounded-md border border-border/60 bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
placeholder="https://..."
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleAddLink() }}
autoFocus
/>
<Button size="sm" disabled={!linkUrl || isAddingLink} onClick={handleAddLink}>
{isAddingLink ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Ajouter'}
</Button>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => { setShowLinkInput(false); setLinkUrl('') }}>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* ── Labels strip + AI suggestions — always visible outside scroll area ─ */}
{((note.labels?.length ?? 0) > 0 || filteredSuggestions.length > 0 || isAnalyzing) && (
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
{/* Existing labels */}
{(note.labels ?? []).map((label) => (
<LabelBadge key={label} label={label} />
))}
{/* AI-suggested tags inline with labels */}
<GhostTags
suggestions={filteredSuggestions}
addedTags={note.labels || []}
isAnalyzing={isAnalyzing}
onSelectTag={handleSelectGhostTag}
onDismissTag={(tag) => setDismissedTags((p) => [...p, tag])}
/>
</div>
)}
{/* ── Scrollable editing area (takes all remaining height) ─────────── */}
<div className="flex flex-1 flex-col overflow-y-auto px-8 py-5">
{/* Title row with optional AI suggest button */}
<div className="group relative flex items-start gap-2 shrink-0">
<input
type="text"
dir="auto"
className="flex-1 bg-transparent text-2xl font-bold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
value={title}
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
/>
{/* AI title suggestion — show when title is empty and there's content */}
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
<button type="button"
onClick={async (e) => {
e.preventDefault()
setIsProcessingAI(true)
try {
const res = await fetch('/api/ai/suggest-title', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
})
if (res.ok) {
const data = await res.json()
const suggested = data.title || data.suggestedTitle || ''
if (suggested) { changeTitle(suggested); scheduleSave() }
}
} catch { /* silent */ } finally { setIsProcessingAI(false) }
}}
disabled={isProcessingAI}
className="mt-1.5 shrink-0 rounded-md p-1 text-muted-foreground/40 opacity-0 transition-all hover:bg-muted hover:text-primary group-hover:opacity-100"
title="Suggestion de titre par IA"
>
{isProcessingAI
? <Loader2 className="h-4 w-4 animate-spin" />
: <Sparkles className="h-4 w-4" />}
</button>
)}
</div>
{/* Title Suggestions Dropdown / Inline list */}
{!title && !dismissedTitleSuggestions && titleSuggestions.length > 0 && (
<div className="mt-2 text-sm shrink-0">
<TitleSuggestions
suggestions={titleSuggestions}
onSelect={(selectedTitle) => { changeTitle(selectedTitle); scheduleSave() }}
onDismiss={() => setDismissedTitleSuggestions(true)}
/>
</div>
)}
{/* Images */}
{Array.isArray(note.images) && note.images.length > 0 && (
<div className="mt-4">
<EditorImages images={note.images} onRemove={handleRemoveImage} />
</div>
)}
{/* Link previews */}
{Array.isArray(note.links) && note.links.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{note.links.map((link, idx) => (
<div key={idx} className="group relative flex overflow-hidden rounded-xl border border-border/60 bg-background/60">
{link.imageUrl && (
<div className="h-auto w-24 shrink-0 bg-cover bg-center" style={{ backgroundImage: `url(${link.imageUrl})` }} />
)}
<div className="flex min-w-0 flex-col justify-center gap-0.5 p-3">
<p className="truncate text-sm font-medium">{link.title || link.url}</p>
{link.description && <p className="line-clamp-1 text-xs text-muted-foreground">{link.description}</p>}
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-[11px] text-primary hover:underline">
{(() => { try { return new URL(link.url).hostname } catch { return link.url } })()}
</a>
</div>
<button type="button"
className="absolute right-2 top-2 rounded-full bg-background/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/10"
onClick={() => handleRemoveLink(idx)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col">
{note.type === 'text' ? (
<div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">
<MarkdownContent content={content || ''} />
</div>
) : (
<textarea
dir="auto"
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
placeholder={isMarkdown
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
: t('notes.takeNote') || 'Écris quelque chose…'
}
value={content}
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
style={{ minHeight: '200px' }}
/>
)}
{/* Ghost tag suggestions are now shown in the top labels strip */}
</div>
) : (
/* Checklist */
<div className="space-y-1">
{checkItems.filter((ci) => !ci.checked).map((ci, index) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 transition-colors hover:bg-muted/30">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/60 transition-colors hover:border-primary"
onClick={() => handleToggleCheckItem(ci.id)}
/>
<input
dir="auto"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/40"
value={ci.text}
placeholder={t('notes.listItem') || 'Élément…'}
onChange={(e) => handleUpdateCheckText(ci.id, e.target.value)}
/>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5 text-muted-foreground/60" />
</button>
</div>
))}
<button type="button"
className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground/60 hover:text-foreground"
onClick={handleAddCheckItem}
>
<Plus className="h-4 w-4" />
{t('notes.addItem') || 'Ajouter un élément'}
</button>
{checkItems.filter((ci) => ci.checked).length > 0 && (
<div className="mt-3">
<p className="mb-1 px-2 text-xs text-muted-foreground/40 uppercase tracking-wider">
Complétés ({checkItems.filter((ci) => ci.checked).length})
</p>
{checkItems.filter((ci) => ci.checked).map((ci) => (
<div key={ci.id} className="group flex items-center gap-2 rounded-lg px-2 py-1 text-muted-foreground transition-colors hover:bg-muted/20">
<button type="button"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border/40 bg-muted/40"
onClick={() => handleToggleCheckItem(ci.id)}
>
<CheckSquare className="h-3 w-3 opacity-60" />
</button>
<span dir="auto" className="flex-1 text-sm line-through">{ci.text}</span>
<button type="button" className="opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveCheckItem(ci.id)}>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* ── Memory Echo Connections Section ── */}
<EditorConnectionsSection
noteId={note.id}
onOpenNote={(connNoteId) => {
window.open(`/?note=${connNoteId}`, '_blank')
}}
onCompareNotes={handleCompareNotes}
onMergeNotes={handleMergeNotes}
/>
{/* ── Footer ───────────────────────────────────────────────────────────── */}
<div className="shrink-0 border-t border-border/20 px-8 py-2">
<div className="flex items-center gap-3 text-[11px] text-muted-foreground/40">
<span>{t('notes.modified') || 'Modifiée'} {formatDistanceToNow(new Date(note.updatedAt), { addSuffix: true, locale: dateLocale })}</span>
<span>·</span>
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
</div>
</div>
{/* Fusion Modal */}
{fusionNotes.length > 0 && (
<FusionModal
isOpen={fusionNotes.length > 0}
onClose={() => setFusionNotes([])}
notes={fusionNotes}
onConfirmFusion={handleConfirmFusion}
/>
)}
{/* Comparison Modal */}
{comparisonNotes.length > 0 && (
<ComparisonModal
isOpen={comparisonNotes.length > 0}
onClose={() => setComparisonNotes([])}
notes={comparisonNotes}
/>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
'use client'
import { Edit2, MoreVertical, FileText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useLanguage } from '@/lib/i18n'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Notebook } from '@/lib/types'
interface NotebookActionsProps {
notebook: Notebook
onEdit: () => void
onDelete: () => void
onSummary?: () => void
}
export function NotebookActions({ notebook, onEdit, onDelete, onSummary }: NotebookActionsProps) {
const { t } = useLanguage()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSummary && (
<DropdownMenuItem onClick={onSummary}>
<FileText className="h-4 w-4 mr-2" />
{t('notebook.summary')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onEdit}>
<Edit2 className="h-4 w-4 mr-2" />
{t('notebook.edit')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={onDelete}
className="text-red-600"
>
{t('notebook.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { X, FolderOpen } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { getNotebookIcon } from '@/lib/notebook-icon'
interface NotebookSuggestionToastProps {
noteId: string
noteContent: string
onDismiss: () => void
onMoveToNotebook?: (notebookId: string) => void
}
export function NotebookSuggestionToast({
noteId,
noteContent,
onDismiss,
onMoveToNotebook
}: NotebookSuggestionToastProps) {
const { t } = useLanguage()
const [suggestion, setSuggestion] = useState<any>(null)
const [isLoading, setIsLoading] = useState(false)
const [visible, setVisible] = useState(true)
const [timeLeft, setTimeLeft] = useState(30) // 30 second countdown
const router = useRouter()
const { moveNoteToNotebookOptimistic } = useNotebooks()
// Auto-dismiss after 30 seconds
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
handleDismiss()
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [])
// Fetch suggestion when component mounts
useEffect(() => {
const fetchSuggestion = async () => {
// Only suggest if content is long enough (> 20 words)
const wordCount = noteContent.trim().split(/\s+/).length
if (wordCount < 20) {
return
}
setIsLoading(true)
try {
const response = await fetch('/api/ai/suggest-notebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
noteContent,
language: document.documentElement.lang || 'en',
})
})
const data = await response.json()
if (response.ok) {
if (data.suggestion && data.confidence > 0.7) {
setSuggestion(data.suggestion)
}
}
} catch (error) {
// Error fetching notebook suggestion
} finally {
setIsLoading(false)
}
}
fetchSuggestion()
}, [noteContent])
const handleDismiss = () => {
setVisible(false)
setTimeout(() => onDismiss(), 300) // Wait for animation
}
const handleMoveToNotebook = async () => {
if (!suggestion) return
try {
// Move note to suggested notebook
await moveNoteToNotebookOptimistic(noteId, suggestion.id)
// No need for router.refresh() - triggerRefresh() is already called in moveNoteToNotebookOptimistic
handleDismiss()
} catch (error) {
console.error('Failed to move note to notebook:', error)
}
}
// Don't render if no suggestion or loading or dismissed
if (!visible || isLoading || !suggestion) {
return null
}
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50 max-w-md bg-white dark:bg-zinc-800',
'border border-border dark:border-border rounded-lg shadow-lg',
'p-4 animate-in slide-in-from-bottom-4 fade-in duration-300',
'transition-all duration-300'
)}
>
<div className="flex items-start gap-3">
{/* Icon */}
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-primary/10 dark:bg-primary/20 flex items-center justify-center">
<FolderOpen className="w-5 h-5 text-primary dark:text-primary-foreground" />
</div>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1.5">
{(() => {
const Icon = getNotebookIcon(suggestion.icon)
return <Icon className="w-4 h-4" />
})()}
{t('notebookSuggestion.title', { name: suggestion.name })}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('notebookSuggestion.description')}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Move button */}
<button
onClick={handleMoveToNotebook}
className="px-3 py-1.5 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{t('notebookSuggestion.move')}
</button>
{/* Dismiss button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 p-1 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
title={t('notebookSuggestion.dismissIn', { timeLeft })}
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,245 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from './ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/dialog'
import { Loader2, FileText, RefreshCw, Download } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
import type { NotebookSummary } from '@/lib/ai/services'
import ReactMarkdown from 'react-markdown'
interface NotebookSummaryDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
notebookId: string | null
notebookName?: string
}
export function NotebookSummaryDialog({
open,
onOpenChange,
notebookId,
notebookName,
}: NotebookSummaryDialogProps) {
const { t } = useLanguage()
const [summary, setSummary] = useState<NotebookSummary | null>(null)
const [loading, setLoading] = useState(false)
const [regenerating, setRegenerating] = useState(false)
// Fetch summary when dialog opens with a notebook
useEffect(() => {
if (open && notebookId) {
fetchSummary()
} else {
// Reset state when closing
setSummary(null)
}
}, [open, notebookId])
const fetchSummary = async () => {
if (!notebookId) return
setLoading(true)
try {
const response = await fetch('/api/ai/notebook-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
notebookId,
language: document.documentElement.lang || 'en',
}),
})
const data = await response.json()
if (data.success && data.data) {
setSummary(data.data)
} else {
toast.error(data.error || t('notebook.summaryError'))
onOpenChange(false)
}
} catch (error) {
toast.error(t('notebook.summaryError'))
onOpenChange(false)
} finally {
setLoading(false)
}
}
const handleRegenerate = async () => {
if (!notebookId) return
setRegenerating(true)
await fetchSummary()
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="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>
</DialogHeader>
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="mt-4 text-sm text-muted-foreground">
{t('notebook.generating')}
</p>
</div>
</DialogContent>
</Dialog>
)
}
if (!summary) {
return null
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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" />
<span className="text-lg font-semibold">{t('notebook.summary')}</span>
</div>
<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,
})}
</p>
</div>
{/* 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

@@ -0,0 +1,314 @@
'use client'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { cn } from '@/lib/utils'
import { StickyNote, Plus, Tag, Folder, ChevronDown, ChevronRight } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
import { useNotebookDrag } from '@/context/notebook-drag-context'
import { Button } from '@/components/ui/button'
import { CreateNotebookDialog } from './create-notebook-dialog'
import { NotebookActions } from './notebook-actions'
import { DeleteNotebookDialog } from './delete-notebook-dialog'
import { EditNotebookDialog } from './edit-notebook-dialog'
import { NotebookSummaryDialog } from './notebook-summary-dialog'
import { useLanguage } from '@/lib/i18n'
import { useLabels } from '@/context/LabelContext'
import { LabelManagementDialog } from '@/components/label-management-dialog'
import { Notebook } from '@/lib/types'
import { getNotebookIcon } from '@/lib/notebook-icon'
export function NotebooksList() {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [editingNotebook, setEditingNotebook] = useState<Notebook | null>(null)
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
const [summaryNotebook, setSummaryNotebook] = useState<Notebook | null>(null)
const [expandedNotebook, setExpandedNotebook] = useState<string | null>(null)
const [labelsDialogOpen, setLabelsDialogOpen] = useState(false)
const currentNotebookId = searchParams.get('notebook')
// Handle drop on a notebook
const handleDrop = useCallback(async (e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
e.stopPropagation()
const noteId = e.dataTransfer.getData('text/plain')
if (noteId) {
await moveNoteToNotebookOptimistic(noteId, notebookId)
}
dragOver(null)
}, [moveNoteToNotebookOptimistic, dragOver])
// Handle drag over a notebook
const handleDragOver = useCallback((e: React.DragEvent, notebookId: string | null) => {
e.preventDefault()
dragOver(notebookId)
}, [dragOver])
// Handle drag leave
const handleDragLeave = useCallback(() => {
dragOver(null)
}, [dragOver])
const handleSelectNotebook = (notebookId: string | null) => {
const params = new URLSearchParams(searchParams)
if (notebookId) {
params.set('notebook', notebookId)
} else {
params.delete('notebook')
}
// Clear other filters
params.delete('labels')
params.delete('search')
router.push(`/?${params.toString()}`)
}
const handleToggleExpand = (notebookId: string) => {
setExpandedNotebook((prev) => (prev === notebookId ? null : notebookId))
}
const handleLabelFilter = (labelName: string, notebookId: string) => {
const params = new URLSearchParams(searchParams)
const currentLabels = params.get('labels')?.split(',').filter(Boolean) || []
if (currentLabels.includes(labelName)) {
params.set('labels', currentLabels.filter((l: string) => l !== labelName).join(','))
} else {
params.set('labels', [...currentLabels, labelName].join(','))
}
params.set('notebook', notebookId)
router.push(`/?${params.toString()}`)
}
if (isLoading) {
return (
<div className="my-2">
<div className="px-4 py-2">
<div className="text-xs text-gray-500">{t('common.loading')}</div>
</div>
</div>
)
}
return (
<>
<LabelManagementDialog open={labelsDialogOpen} onOpenChange={setLabelsDialogOpen} />
<div className="flex flex-col pt-1">
{/* Header with Add Button */}
<div className="flex items-center justify-between px-6 py-2 mt-2 group cursor-pointer text-gray-500 hover:text-gray-800 dark:hover:text-gray-300">
<span className="text-xs font-semibold uppercase tracking-wider">{t('nav.notebooks') || 'NOTEBOOKS'}</span>
<button
onClick={() => setIsCreateDialogOpen(true)}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-full transition-colors"
title={t('notebooks.create') || 'Create notebook'}
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Notebooks Loop */}
{notebooks.map((notebook: Notebook) => {
const isActive = currentNotebookId === notebook.id
const isExpanded = expandedNotebook === notebook.id
const isDragOver = dragOverNotebookId === notebook.id
// Get icon component
const NotebookIcon = getNotebookIcon(notebook.icon || 'folder')
return (
<div key={notebook.id} className="group flex flex-col">
{isActive ? (
// Active notebook with expanded labels
<div
onDrop={(e) => handleDrop(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave}
className={cn(
"flex flex-col me-2 rounded-e-full transition-all relative",
!notebook.color && "bg-primary/10 dark:bg-primary/20",
isDragOver && "ring-2 ring-primary ring-dashed"
)}
style={notebook.color ? { backgroundColor: `${notebook.color}20` } : undefined}
>
{/* Header */}
<div className="pointer-events-auto flex items-center justify-between px-6 py-3">
<div className="flex items-center gap-4 min-w-0 flex-1">
<NotebookIcon
className={cn("w-5 h-5 flex-shrink-0 fill-current", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
/>
<span
className={cn("text-sm font-medium tracking-wide truncate min-w-0", !notebook.color && "text-primary dark:text-primary-foreground")}
style={notebook.color ? { color: notebook.color } : undefined}
>
{notebook.name}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Actions menu for active notebook */}
<NotebookActions
notebook={notebook}
onEdit={() => setEditingNotebook(notebook)}
onDelete={() => setDeletingNotebook(notebook)}
onSummary={() => setSummaryNotebook(notebook)}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleToggleExpand(notebook.id)
}}
className={cn(
"shrink-0 rounded-full p-1 transition-colors",
!notebook.color &&
"text-primary hover:text-primary/80 dark:text-primary-foreground dark:hover:text-primary-foreground/80"
)}
style={notebook.color ? { color: notebook.color } : undefined}
aria-expanded={isExpanded}
>
<ChevronDown className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-180")} />
</button>
</div>
</div>
{/* Contextual Labels Tree */}
{isExpanded && (
<div className="flex flex-col pb-2">
{labels.length === 0 ? (
<p className="pointer-events-none ps-12 pe-4 py-2 text-xs text-muted-foreground">
{t('sidebar.noLabelsInNotebook')}
</p>
) : (
labels.map((label: any) => (
<button
key={label.id}
type="button"
onClick={() => handleLabelFilter(label.name, notebook.id)}
className={cn(
'pointer-events-auto flex items-center gap-4 ps-12 pe-4 py-2 rounded-e-full me-2 transition-colors',
'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
searchParams.get('labels')?.includes(label.name) &&
'font-semibold text-foreground'
)}
>
<Tag className="h-4 w-4 shrink-0" />
<span className="text-xs font-medium truncate">{label.name}</span>
</button>
))
)}
<button
type="button"
onClick={() => setLabelsDialogOpen(true)}
className="pointer-events-auto flex items-center gap-2 ps-12 pe-4 py-2 mt-1 rounded-e-full me-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
>
<Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
<span className="text-xs font-medium">{t('sidebar.editLabels')}</span>
</button>
</div>
)}
</div>
) : (
// Inactive notebook
<div
onDrop={(e) => handleDrop(e, notebook.id)}
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave}
className={cn(
"flex items-center relative",
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
)}
>
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-24",
isDragOver && "opacity-50"
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}
</button>
{/* Actions + expand on the right — always rendered, visible on hover */}
<div className="absolute end-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<NotebookActions
notebook={notebook}
onEdit={() => setEditingNotebook(notebook)}
onDelete={() => setDeletingNotebook(notebook)}
onSummary={() => setSummaryNotebook(notebook)}
/>
<button
onClick={(e) => { e.stopPropagation(); handleToggleExpand(notebook.id); }}
className={cn(
"p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors rounded-full hover:bg-gray-200 dark:hover:bg-gray-700",
expandedNotebook === notebook.id && "rotate-180"
)}
>
<ChevronDown className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
})}
</div>
{/* Create Notebook Dialog */}
<CreateNotebookDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
{/* Edit Notebook Dialog */}
{editingNotebook && (
<EditNotebookDialog
notebook={editingNotebook}
open={!!editingNotebook}
onOpenChange={(open) => {
if (!open) setEditingNotebook(null)
}}
/>
)}
{/* Delete Confirmation Dialog */}
{deletingNotebook && (
<DeleteNotebookDialog
notebook={deletingNotebook}
open={!!deletingNotebook}
onOpenChange={(open) => {
if (!open) setDeletingNotebook(null)
}}
/>
)}
{/* Notebook Summary Dialog */}
<NotebookSummaryDialog
open={!!summaryNotebook}
onOpenChange={(open) => {
if (!open) setSummaryNotebook(null)
}}
notebookId={summaryNotebook?.id ?? null}
notebookName={summaryNotebook?.name}
/>
</>
)
}

View File

@@ -0,0 +1,44 @@
'use client'
import dynamic from 'next/dynamic'
import { Note } from '@/lib/types'
import { NotesTabsView } from '@/components/notes-tabs-view'
const MasonryGridLazy = dynamic(
() => import('@/components/masonry-grid').then((m) => m.MasonryGrid),
{
ssr: false,
loading: () => (
<div
className="min-h-[200px] rounded-xl border border-dashed border-muted-foreground/20 bg-muted/30 animate-pulse"
aria-hidden
/>
),
}
)
export type NotesViewMode = 'masonry' | 'tabs'
interface NotesMainSectionProps {
notes: Note[]
viewMode: NotesViewMode
onEdit?: (note: Note, readOnly?: boolean) => void
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void
currentNotebookId?: string | null
}
export function NotesMainSection({ notes, viewMode, onEdit, onSizeChange, currentNotebookId }: NotesMainSectionProps) {
if (viewMode === 'tabs') {
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="notes-grid-tabs-wrap">
<NotesTabsView notes={notes} onEdit={onEdit} currentNotebookId={currentNotebookId} />
</div>
)
}
return (
<div data-testid="notes-grid">
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
</div>
)
}

View File

@@ -0,0 +1,513 @@
'use client'
import { useCallback, useEffect, useState, useTransition } from 'react'
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { cn } from '@/lib/utils'
import { NoteInlineEditor } from '@/components/note-inline-editor'
import { useLanguage } from '@/lib/i18n'
import { getNoteDisplayTitle } from '@/lib/note-preview'
import { updateFullOrderWithoutRevalidation, createNote, deleteNote } from '@/app/actions/notes'
import {
GripVertical,
Hash,
ListChecks,
Pin,
FileText,
Clock,
Plus,
Loader2,
Trash2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from 'sonner'
import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US'
interface NotesTabsViewProps {
notes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
currentNotebookId?: string | null
}
// Color accent strip for each note
const COLOR_ACCENT: Record<NoteColor, string> = {
default: 'bg-primary',
red: 'bg-red-400',
orange: 'bg-orange-400',
yellow: 'bg-amber-400',
green: 'bg-emerald-400',
teal: 'bg-teal-400',
blue: 'bg-sky-400',
purple: 'bg-violet-400',
pink: 'bg-fuchsia-400',
gray: 'bg-gray-400',
}
// Background tint gradient for selected note panel
const COLOR_PANEL_BG: Record<NoteColor, string> = {
default: 'from-background to-background',
red: 'from-red-50/60 dark:from-red-950/20 to-background',
orange: 'from-orange-50/60 dark:from-orange-950/20 to-background',
yellow: 'from-amber-50/60 dark:from-amber-950/20 to-background',
green: 'from-emerald-50/60 dark:from-emerald-950/20 to-background',
teal: 'from-teal-50/60 dark:from-teal-950/20 to-background',
blue: 'from-sky-50/60 dark:from-sky-950/20 to-background',
purple: 'from-violet-50/60 dark:from-violet-950/20 to-background',
pink: 'from-fuchsia-50/60 dark:from-fuchsia-950/20 to-background',
gray: 'from-gray-50/60 dark:from-gray-900/20 to-background',
}
const COLOR_ICON: Record<NoteColor, string> = {
default: 'text-primary',
red: 'text-red-500',
orange: 'text-orange-500',
yellow: 'text-amber-500',
green: 'text-emerald-500',
teal: 'text-teal-500',
blue: 'text-sky-500',
purple: 'text-violet-500',
pink: 'text-fuchsia-500',
gray: 'text-gray-500',
}
function getColorKey(note: Note): NoteColor {
return (typeof note.color === 'string' && note.color in NOTE_COLORS
? note.color
: 'default') as NoteColor
}
function getDateLocale(language: string) {
if (language === 'fr') return fr;
if (language === 'fa') return require('date-fns/locale').faIR;
return enUS;
}
// ─── Sortable List Item ───────────────────────────────────────────────────────
function SortableNoteListItem({
note,
selected,
onSelect,
onDelete,
reorderLabel,
deleteLabel,
language,
untitledLabel,
}: {
note: Note
selected: boolean
onSelect: () => void
onDelete: () => void
reorderLabel: string
deleteLabel: string
language: string
untitledLabel: string
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: note.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
}
const ck = getColorKey(note)
const title = getNoteDisplayTitle(note, untitledLabel)
const snippet =
note.type === 'checklist'
? (note.checkItems?.map((i) => i.text).join(' · ') || '').substring(0, 150)
: (note.content || '').substring(0, 150)
const dateLocale = getDateLocale(language)
const timeAgo = formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: dateLocale,
})
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'group relative flex cursor-pointer select-none items-stretch gap-0 rounded-xl transition-all duration-150',
'border',
selected
? 'border-primary/20 bg-primary/5 dark:bg-primary/10 shadow-sm'
: 'border-transparent hover:border-border/60 hover:bg-muted/50',
isDragging && 'opacity-80 shadow-xl ring-2 ring-primary/30'
)}
onClick={onSelect}
role="option"
aria-selected={selected}
>
{/* Color accent bar */}
<div
className={cn(
'w-1 shrink-0 rounded-s-xl transition-all duration-200',
selected ? COLOR_ACCENT[ck] : 'bg-transparent group-hover:bg-border/40'
)}
/>
{/* Drag handle */}
<button
type="button"
className="flex cursor-grab items-center px-1.5 text-muted-foreground/30 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
aria-label={reorderLabel}
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
{/* Note type icon */}
<div className="flex items-center py-4 pe-1">
{note.type === 'checklist' ? (
<ListChecks
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
) : (
<FileText
className={cn(
'h-4 w-4 shrink-0 transition-colors',
selected ? COLOR_ICON[ck] : 'text-muted-foreground/50 group-hover:text-muted-foreground'
)}
/>
)}
</div>
{/* Text content */}
<div className="min-w-0 flex-1 py-3.5 pe-3">
<div className="flex items-center gap-2">
<p
className={cn(
'truncate text-sm font-medium transition-colors',
selected ? 'text-foreground' : 'text-foreground/80 group-hover:text-foreground'
)}
>
{title}
</p>
{note.isPinned && (
<Pin className="h-3 w-3 shrink-0 fill-current text-primary" aria-label="Épinglée" />
)}
</div>
{snippet && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">{snippet}</p>
)}
<div className="mt-1.5 flex items-center gap-2">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50">
<Clock className="h-2.5 w-2.5" />
{timeAgo}
</span>
{Array.isArray(note.labels) && note.labels.length > 0 && (
<>
<span className="text-muted-foreground/30">·</span>
<div className="flex items-center gap-1">
<Hash className="h-2.5 w-2.5 text-muted-foreground/40" />
<span className="truncate text-[11px] text-muted-foreground/50">
{note.labels.slice(0, 2).join(', ')}
{note.labels.length > 2 && ` +${note.labels.length - 2}`}
</span>
</div>
</>
)}
</div>
</div>
{/* Delete button - visible on hover */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="flex items-center px-2 text-red-500/60 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
aria-label={deleteLabel}
title={deleteLabel}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsViewProps) {
const { t, language } = useLanguage()
const [items, setItems] = useState<Note[]>(notes)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [isCreating, startCreating] = useTransition()
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null)
useEffect(() => {
// Only reset when notes are added or removed, NOT on content/field changes
// Field changes arrive through onChange -> setItems already
setItems((prev) => {
const prevIds = prev.map((n) => n.id).join(',')
const incomingIds = notes.map((n) => n.id).join(',')
if (prevIds === incomingIds) {
// Same set of notes: merge only structural fields (pin, color, archive)
return prev.map((p) => {
const fresh = notes.find((n) => n.id === p.id)
if (!fresh) return p
// Use fresh labels from server if they've changed (e.g., global label deletion)
const labelsChanged = JSON.stringify(fresh.labels?.sort()) !== JSON.stringify(p.labels?.sort())
return {
...fresh,
title: p.title,
content: p.content,
// Always use server labels if different (for global label changes)
labels: labelsChanged ? fresh.labels : p.labels
}
})
}
// Different set (add/remove): full sync
return notes
})
}, [notes])
useEffect(() => {
if (items.length === 0) {
setSelectedId(null)
return
}
setSelectedId((prev) =>
prev && items.some((n) => n.id === prev) ? prev : items[0].id
)
}, [items])
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = items.findIndex((n) => n.id === active.id)
const newIndex = items.findIndex((n) => n.id === over.id)
if (oldIndex < 0 || newIndex < 0) return
const reordered = arrayMove(items, oldIndex, newIndex)
setItems(reordered)
try {
await updateFullOrderWithoutRevalidation(reordered.map((n) => n.id))
} catch {
setItems(notes)
toast.error(t('notes.moveFailed'))
}
},
[items, notes, t]
)
const selected = items.find((n) => n.id === selectedId) ?? null
const colorKey = selected ? getColorKey(selected) : 'default'
/** Create a new blank note, add it to the sidebar and select it immediately */
const handleCreateNote = () => {
startCreating(async () => {
try {
const newNote = await createNote({
content: '',
title: undefined,
notebookId: currentNotebookId || undefined,
skipRevalidation: true
})
if (!newNote) return
setItems((prev) => [newNote, ...prev])
setSelectedId(newNote.id)
} catch {
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
}
})
}
if (items.length === 0) {
return (
<div
className="flex min-h-[240px] flex-col items-center justify-center rounded-2xl border border-dashed border-border/80 bg-muted/20 px-6 py-12 text-center"
data-testid="notes-grid-tabs-empty"
>
<p className="max-w-md text-sm text-muted-foreground">{t('notes.emptyStateTabs')}</p>
</div>
)
}
return (
<div
className="flex min-h-0 flex-1 gap-0 overflow-hidden rounded-2xl border border-border/60 shadow-sm"
style={{ height: 'max(360px, min(85vh, calc(100vh - 9rem)))' }}
data-testid="notes-grid-tabs"
>
{/* ── Left sidebar: note list ── */}
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 bg-muted/20">
{/* Sidebar header with note count + new note button */}
<div className="border-b border-border/40 px-3 py-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60">
{t('notes.title')}
<span className="ms-2 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
{items.length}
</span>
</span>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={handleCreateNote}
disabled={isCreating}
title={t('notes.newNote') || 'Nouvelle note'}
>
{isCreating
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{/* Scrollable note list */}
<div
className="flex-1 overflow-y-auto overscroll-contain p-2"
role="listbox"
aria-label={t('notes.viewTabs')}
>
<DndContext
id="notes-tabs-dnd"
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((n) => n.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-0.5">
{items.map((note) => (
<SortableNoteListItem
key={note.id}
note={note}
selected={note.id === selectedId}
onSelect={() => setSelectedId(note.id)}
onDelete={() => setNoteToDelete(note)}
reorderLabel={t('notes.reorderTabs')}
deleteLabel={t('notes.delete')}
language={language}
untitledLabel={t('notes.untitled')}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
{/* ── Right content panel — always in edit mode ── */}
{selected ? (
<div
className={cn(
'flex min-w-0 flex-1 flex-col overflow-hidden bg-gradient-to-br',
COLOR_PANEL_BG[colorKey]
)}
>
<NoteInlineEditor
key={selected.id}
note={selected}
colorKey={colorKey}
defaultPreviewMode={true}
onChange={(noteId, fields) => {
setItems((prev) =>
prev.map((n) => (n.id === noteId ? { ...n, ...fields } : n))
)
}}
onDelete={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
onArchive={(noteId) => {
setItems((prev) => prev.filter((n) => n.id !== noteId))
setSelectedId((prev) => (prev === noteId ? null : prev))
}}
/>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-muted-foreground/40">
<p className="text-sm">{t('notes.selectNote') || 'Sélectionnez une note'}</p>
</div>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={!!noteToDelete} onOpenChange={() => setNoteToDelete(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</DialogTitle>
<DialogDescription>
{t('notes.confirmDelete') || 'Are you sure you want to delete this note?'}
{noteToDelete && (
<span className="mt-2 block font-medium text-foreground">
"{getNoteDisplayTitle(noteToDelete, t('notes.untitled'))}"
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setNoteToDelete(null)}>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
if (!noteToDelete) return
try {
await deleteNote(noteToDelete.id)
setItems((prev) => prev.filter((n) => n.id !== noteToDelete.id))
setSelectedId((prev) => (prev === noteToDelete.id ? null : prev))
setNoteToDelete(null)
toast.success(t('notes.deleted'))
} catch {
toast.error(t('notes.deleteFailed'))
}
}}
>
{t('notes.delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useTransition } from 'react'
import { LayoutGrid, PanelsTopLeft } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { updateAISettings } from '@/app/actions/ai-settings'
import { useLanguage } from '@/lib/i18n'
import type { NotesViewMode } from '@/components/notes-main-section'
interface NotesViewToggleProps {
mode: NotesViewMode
onModeChange: (mode: NotesViewMode) => void
className?: string
}
export function NotesViewToggle({ mode, onModeChange, className }: NotesViewToggleProps) {
const { t, language } = useLanguage()
const [pending, startTransition] = useTransition()
const setMode = (next: NotesViewMode) => {
if (next === mode) return
const previous = mode
onModeChange(next)
startTransition(async () => {
try {
await updateAISettings({ notesViewMode: next })
} catch {
onModeChange(previous)
}
})
}
return (
<TooltipProvider delayDuration={300}>
<div
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
className={cn(
'inline-flex rounded-full border border-border bg-muted/40 p-0.5 shadow-sm',
className
)}
role="group"
aria-label={t('notes.viewModeGroup')}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'masonry' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('masonry')}
aria-pressed={mode === 'masonry'}
>
<LayoutGrid className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewCards')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewCardsTooltip')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={pending}
className={cn(
'h-9 rounded-full px-3 gap-1.5',
mode === 'tabs' && 'bg-background shadow-sm text-foreground'
)}
onClick={() => setMode('tabs')}
aria-pressed={mode === 'tabs'}
>
<PanelsTopLeft className="h-4 w-4" aria-hidden />
<span className="hidden sm:inline text-xs font-medium">{t('notes.viewTabs')}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('notes.viewTabsTooltip')}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Bell, Check, X, Clock } from 'lucide-react'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getPendingShareRequests, respondToShareRequest } from '@/app/actions/notes'
import { toast } from 'sonner'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface ShareRequest {
id: string
status: string
permission: string
createdAt: Date
note: {
id: string
title: string | null
content: string
color: string
createdAt: Date
}
sharer: {
id: string
name: string | null
email: string
image: string | null
}
}
export function NotificationPanel() {
const { triggerRefresh } = useNoteRefresh()
const { t } = useLanguage()
const [requests, setRequests] = useState<ShareRequest[]>([])
const [isLoading, setIsLoading] = useState(false)
const [open, setOpen] = useState(false)
const loadRequests = useCallback(async () => {
try {
const data = await getPendingShareRequests()
setRequests(data as any)
} catch (error: any) {
console.error('Failed to load share requests:', error)
}
}, [])
useEffect(() => {
loadRequests()
const interval = setInterval(loadRequests, 10000)
const onFocus = () => loadRequests()
window.addEventListener('focus', onFocus)
return () => {
clearInterval(interval)
window.removeEventListener('focus', onFocus)
}
}, [loadRequests])
const pendingCount = requests.length
const handleAccept = async (shareId: string) => {
try {
await respondToShareRequest(shareId, 'accept')
setRequests(prev => prev.filter(r => r.id !== shareId))
toast.success(t('notification.accepted'), {
description: t('collaboration.nowHasAccess', { name: 'Note' }),
duration: 3000,
})
triggerRefresh()
setOpen(false)
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || t('general.error'))
}
}
const handleDecline = async (shareId: string) => {
try {
await respondToShareRequest(shareId, 'decline')
setRequests(prev => prev.filter(r => r.id !== shareId))
toast.info(t('notification.declined'))
if (requests.length <= 1) setOpen(false)
} catch (error: any) {
console.error('[NOTIFICATION] Error:', error)
toast.error(error.message || t('general.error'))
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-9 w-9 p-0 hover:bg-accent/50 transition-all duration-200"
>
<Bell className="h-4 w-4 transition-transform duration-200 hover:scale-110" />
{pendingCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs animate-pulse shadow-lg"
>
{pendingCount > 9 ? '9+' : pendingCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="px-4 py-3 border-b bg-gradient-to-r from-primary/5 to-primary/10 dark:from-primary/10 dark:to-primary/15">
<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('notification.notifications')}</span>
</div>
{pendingCount > 0 && (
<Badge className="bg-primary hover:bg-primary/90 text-primary-foreground shadow-md">
{pendingCount}
</Badge>
)}
</div>
</div>
{isLoading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
</div>
) : requests.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
<Bell className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p>
</div>
) : (
<div className="max-h-96 overflow-y-auto">
{requests.map((request) => (
<div
key={request.id}
className="p-4 border-b last:border-0 hover:bg-accent/50 transition-colors duration-150"
>
<div className="flex items-start gap-3 mb-3">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-semibold text-xs shadow-md shrink-0">
{(request.sharer.name || request.sharer.email)[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">
{request.sharer.name || request.sharer.email}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
</p>
</div>
</div>
<div className="flex gap-2 mt-3">
<button
onClick={() => handleDecline(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"border border-border bg-background",
"text-muted-foreground",
"hover:bg-muted hover:text-foreground",
"transition-all duration-200",
"flex items-center justify-center gap-1.5",
"active:scale-95"
)}
>
<X className="h-3.5 w-3.5" />
{t('notification.decline') || t('general.cancel')}
</button>
<button
onClick={() => handleAccept(request.id)}
className={cn(
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
"bg-primary text-primary-foreground",
"hover:bg-primary/90",
"shadow-sm hover:shadow",
"transition-all duration-200",
"flex items-center justify-center gap-1.5",
"active:scale-95"
)}
>
<Check className="h-3.5 w-3.5" />
{t('notification.accept') || t('general.confirm')}
</button>
</div>
<div className="flex items-center gap-1.5 mt-3 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{new Date(request.createdAt).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,28 @@
'use client'
import { useLanguage } from '@/lib/i18n'
export function ProfilePageHeader() {
const { t } = useLanguage()
return (
<h1 className="text-3xl font-bold mb-8">{t('nav.accountSettings')}</h1>
)
}
export function AISettingsCard() {
const { t } = useLanguage()
return (
<>
<div className="flex items-center gap-2">
<span className="text-2xl"></span>
{t('nav.aiSettings')}
</div>
<p className="text-sm text-muted-foreground">
{t('nav.configureAI')}
</p>
<span>{t('nav.manageAISettings')}</span>
</>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import { LanguageProvider, useLanguage } from '@/lib/i18n/LanguageProvider'
import { LabelProvider } from '@/context/LabelContext'
import { NotebooksProvider } from '@/context/notebooks-context'
import { NotebookDragProvider } from '@/context/notebook-drag-context'
import { NoteRefreshProvider } from '@/context/NoteRefreshContext'
import { HomeViewProvider } from '@/context/home-view-context'
import type { ReactNode } from 'react'
import type { Translations } from '@/lib/i18n/load-translations'
const RTL_LANGUAGES = ['ar', 'fa']
/** Sets `dir` on its own DOM node from React state — immune to third-party JS overwriting documentElement.dir. */
function DirWrapper({ children }: { children: ReactNode }) {
const { language } = useLanguage()
const dir = RTL_LANGUAGES.includes(language) ? 'rtl' : 'ltr'
return <div dir={dir} className="contents">{children}</div>
}
interface ProvidersWrapperProps {
children: ReactNode
initialLanguage?: string
initialTranslations?: Translations
}
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
return (
<NoteRefreshProvider>
<LabelProvider>
<NotebooksProvider>
<NotebookDragProvider>
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
<DirWrapper>
<HomeViewProvider>{children}</HomeViewProvider>
</DirWrapper>
</LanguageProvider>
</NotebookDragProvider>
</NotebooksProvider>
</LabelProvider>
</NoteRefreshProvider>
)
}

View File

@@ -0,0 +1,288 @@
'use client'
import { useState, useTransition, useOptimistic } from 'react'
import { Note } from '@/lib/types'
import { Clock, Pin, FolderOpen, Trash2, Folder, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
import { Button } from './ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'
import { togglePin, deleteNote, dismissFromRecent } from '@/app/actions/notes'
import { useRouter } from 'next/navigation'
import { useNotebooks } from '@/context/notebooks-context'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { toast } from 'sonner'
import { StickyNote } from 'lucide-react'
interface RecentNotesSectionProps {
recentNotes: Note[]
onEdit?: (note: Note, readOnly?: boolean) => void
}
export function RecentNotesSection({ recentNotes, onEdit }: RecentNotesSectionProps) {
const { t } = useLanguage()
const topThree = recentNotes.slice(0, 3)
if (topThree.length === 0) return null
return (
<section data-testid="recent-notes-section" className="mb-6">
<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">
{t('notes.recent')}
</span>
<span className="text-xs text-muted-foreground">
· {topThree.length}
</span>
</div>
<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>
)
}
function CompactCard({
note,
index,
onEdit
}: {
note: Note
index: number
onEdit?: (note: Note, readOnly?: boolean) => void
}) {
const { t } = useLanguage()
const router = useRouter()
const { notebooks, moveNoteToNotebookOptimistic } = useNotebooks()
const { triggerRefresh } = useNoteRefresh()
const [isDeleting, setIsDeleting] = useState(false)
const [showNotebookMenu, setShowNotebookMenu] = useState(false)
const [, startTransition] = useTransition()
// Optimistic UI state
const [optimisticNote, addOptimisticNote] = useOptimistic(
note,
(state: Note, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
const timeAgo = getCompactTime(optimisticNote.contentUpdatedAt || optimisticNote.updatedAt, t)
const isFirstNote = index === 0
const handleTogglePin = async (e: React.MouseEvent) => {
e.stopPropagation()
startTransition(async () => {
const newPinnedState = !optimisticNote.isPinned
addOptimisticNote({ isPinned: newPinnedState })
await togglePin(note.id, newPinnedState)
// Trigger global refresh to update lists
triggerRefresh()
router.refresh()
if (newPinnedState) {
toast.success(t('notes.pinned') || 'Note pinned')
} else {
toast.info(t('notes.unpinned') || 'Note unpinned')
}
})
}
const handleMoveToNotebook = async (notebookId: string | null) => {
await moveNoteToNotebookOptimistic(note.id, notebookId)
setShowNotebookMenu(false)
triggerRefresh()
}
const handleDelete = async (e: React.MouseEvent) => {
e.stopPropagation()
toast(t('notes.confirmDelete'), {
action: {
label: t('notes.delete'),
onClick: async () => {
setIsDeleting(true)
try {
await deleteNote(note.id)
triggerRefresh()
router.refresh()
} catch (error) {
console.error('Failed to delete note:', error)
setIsDeleting(false)
}
},
},
cancel: {
label: t('common.cancel'),
onClick: () => {},
},
duration: 5000,
})
}
const handleDismiss = async (e: React.MouseEvent) => {
e.stopPropagation()
// Optimistic removal
setIsDeleting(true)
try {
await dismissFromRecent(note.id)
// Don't refresh list to prevent immediate replacement
// triggerRefresh()
// router.refresh()
toast.success(t('notes.dismissed') || 'Note dismissed from recent')
} catch (error) {
console.error('Failed to dismiss note:', error)
setIsDeleting(false)
toast.error(t('common.error') || 'Failed to dismiss')
}
}
if (isDeleting) return null
return (
<div
className={cn(
"group relative flex flex-col 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",
"cursor-pointer"
)}
onClick={() => onEdit?.(note)}
>
<div className={cn(
"absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
isFirstNote
? "bg-gradient-to-b from-primary to-primary/70"
: index === 1
? "bg-primary/80 dark:bg-primary/70"
: "bg-muted dark:bg-muted/60"
)} />
{/* Action Buttons Overlay - Ensure visible on hover and touch devices often handle this with tap */}
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity z-10">
{/* Pin Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={handleTogglePin}
title={optimisticNote.isPinned ? t('notes.unpin') : t('notes.pin')}
>
<Pin className={cn("h-3.5 w-3.5", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")} />
</Button>
{/* Move to Notebook */}
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={(e) => e.stopPropagation()}
title={t('notebookSuggestion.moveToNotebook')}
>
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" onClick={(e) => e.stopPropagation()}>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{t('notebookSuggestion.moveToNotebook')}
</div>
<DropdownMenuItem onClick={() => handleMoveToNotebook(null)}>
<StickyNote className="h-4 w-4 mr-2" />
{t('notebookSuggestion.generalNotes')}
</DropdownMenuItem>
{notebooks.map((notebook: any) => (
<DropdownMenuItem
key={notebook.id}
onClick={() => handleMoveToNotebook(notebook.id)}
>
<Folder className="h-4 w-4 mr-2" />
{notebook.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Dismiss Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:text-destructive bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={handleDismiss}
title={t('common.close') || 'Dismiss'}
>
<X className="h-3.5 w-3.5" />
</Button>
{/* Delete Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:text-destructive bg-background/50 backdrop-blur-sm border shadow-sm md:shadow-none md:border-none md:bg-transparent"
onClick={handleDelete}
title={t('notes.delete')}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="pl-2">
<h3 className="text-sm font-semibold text-foreground line-clamp-1 mb-2">
{optimisticNote.title || t('notes.untitled')}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2.5rem]">
{(optimisticNote.content && typeof optimisticNote.content === 'string') ? optimisticNote.content.substring(0, 80) : ''}
{optimisticNote.content && typeof optimisticNote.content === 'string' && optimisticNote.content.length > 80 && '...'}
</p>
<div className="flex items-center justify-between pt-2 border-t border-border">
<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>
<div className="flex items-center gap-1.5">
{optimisticNote.isPinned && (
<Pin className="w-3 h-3 text-primary fill-current" />
)}
{optimisticNote.notebookId && (
<div className="w-1.5 h-1.5 rounded-full bg-primary dark:bg-primary/70" title={t('notes.inNotebook')} />
)}
{optimisticNote.labels && optimisticNote.labels.length > 0 && (
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" title={t('labels.count', { count: optimisticNote.labels.length })} />
)}
</div>
</div>
</div>
</div>
)
}
function getCompactTime(date: Date | string, t: (key: string, params?: Record<string, any>) => string): string {
const now = new Date()
const then = date instanceof Date ? date : new Date(date)
if (isNaN(then.getTime())) {
console.warn('Invalid date provided to getCompactTime:', date)
return t('common.error')
}
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (seconds < 60) return t('time.justNow')
if (minutes < 60) return t('time.minutesAgo', { count: minutes })
if (hours < 24) return t('time.hoursAgo', { count: hours })
const days = Math.floor(hours / 24)
return t('time.daysAgo', { count: days })
}

View File

@@ -0,0 +1,107 @@
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { register } from '@/app/actions/register';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link';
import { useLanguage } from '@/lib/i18n';
function RegisterButton() {
const { pending } = useFormStatus();
const { t } = useLanguage();
return (
<Button className="w-full mt-4" aria-disabled={pending}>
{t('auth.signUp')}
</Button>
);
}
export function RegisterForm() {
const [errorMessage, dispatch] = useActionState(register, undefined);
const { t } = useLanguage();
return (
<form action={dispatch} className="space-y-3">
<div className="flex-1 rounded-lg bg-gray-50 px-6 pb-4 pt-8">
<h1 className="mb-3 text-2xl font-bold">
{t('auth.createAccount')}
</h1>
<div className="w-full">
<div>
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="name"
>
{t('auth.name')}
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="name"
type="text"
name="name"
placeholder={t('auth.namePlaceholder')}
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="email"
>
{t('auth.email')}
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="email"
type="email"
name="email"
placeholder={t('auth.emailPlaceholder')}
required
/>
</div>
</div>
<div className="mt-4">
<label
className="mb-3 mt-5 block text-xs font-medium text-gray-900"
htmlFor="password"
>
{t('auth.password')}
</label>
<div className="relative">
<Input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
id="password"
type="password"
name="password"
placeholder={t('auth.passwordMinChars')}
required
minLength={6}
/>
</div>
</div>
</div>
<RegisterButton />
<div
className="flex h-8 items-end space-x-1"
aria-live="polite"
aria-atomic="true"
>
{errorMessage && (
<p className="text-sm text-red-500">{errorMessage}</p>
)}
</div>
<div className="mt-4 text-center text-sm">
{t('auth.hasAccount')}{' '}
<Link href="/login" className="underline">
{t('auth.signIn')}
</Link>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,129 @@
'use client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useState, useEffect } from "react"
import { useLanguage } from '@/lib/i18n'
interface ReminderDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
currentReminder: Date | null
onSave: (date: Date) => void
onRemove: () => void
}
export function ReminderDialog({
open,
onOpenChange,
currentReminder,
onSave,
onRemove
}: ReminderDialogProps) {
const { t } = useLanguage()
const [reminderDate, setReminderDate] = useState('')
const [reminderTime, setReminderTime] = useState('')
useEffect(() => {
if (open) {
if (currentReminder) {
const date = new Date(currentReminder)
setReminderDate(date.toISOString().split('T')[0])
setReminderTime(date.toTimeString().slice(0, 5))
} else {
const tomorrow = new Date(Date.now() + 86400000)
setReminderDate(tomorrow.toISOString().split('T')[0])
setReminderTime('09:00')
}
}
}, [open, currentReminder])
const handleSave = () => {
if (!reminderDate || !reminderTime) return
const dateTimeString = `${reminderDate}T${reminderTime}`
const date = new Date(dateTimeString)
if (!isNaN(date.getTime())) {
onSave(date)
onOpenChange(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onInteractOutside={(event) => {
const target = event.target as HTMLElement;
const isSonnerElement =
target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') ||
target.closest('[data-icon]') ||
target.closest('[data-content]') ||
target.closest('[data-description]') ||
target.closest('[data-title]') ||
target.closest('[data-button]');
if (isSonnerElement) {
event.preventDefault();
return;
}
if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault();
return;
}
}}
>
<DialogHeader>
<DialogTitle>{t('reminder.setReminder')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label htmlFor="reminder-date" className="text-sm font-medium">
{t('reminder.reminderDate')}
</label>
<Input
id="reminder-date"
type="date"
value={reminderDate}
onChange={(e) => setReminderDate(e.target.value)}
className="w-full"
/>
</div>
<div className="space-y-2">
<label htmlFor="reminder-time" className="text-sm font-medium">
{t('reminder.reminderTime')}
</label>
<Input
id="reminder-time"
type="time"
value={reminderTime}
onChange={(e) => setReminderTime(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="flex justify-between">
<div>
{currentReminder && (
<Button variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
{t('reminder.removeReminder')}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t('reminder.cancel')}
</Button>
<Button onClick={handleSave}>
{t('reminder.save')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

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

@@ -0,0 +1,7 @@
'use client'
import { SessionProvider } from 'next-auth/react'
export function SessionProviderWrapper({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}

View File

@@ -0,0 +1,87 @@
'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'
import { useLanguage } from '@/lib/i18n'
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 { t } = useLanguage()
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(t('toast.saved'))
setTimeout(() => setIsSaved(false), 2000)
} catch (err) {
console.error('Error updating setting:', err)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<input
type={type}
value={value}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100',
'placeholder:text-gray-400 dark:placeholder:text-gray-600'
)}
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
{isSaved && !isLoading && (
<Check className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
'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'
import { useLanguage } from '@/lib/i18n'
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 { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const handleChange = async (newValue: string) => {
setIsLoading(true)
try {
await onChange(newValue)
toast.success(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<select
value={value}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'appearance-none bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100'
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
'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'
import { useLanguage } from '@/lib/i18n'
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 { t } = useLanguage()
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(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
setError(true)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn(
'flex items-center justify-between py-4',
'border-b last:border-0 dark:border-gray-800'
)}>
<div className="flex-1 pr-4">
<Label className="font-medium text-gray-900 dark:text-gray-100 cursor-pointer">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-gray-500" />}
{!isLoading && !error && checked && <Check className="h-4 w-4 text-green-500" />}
{!isLoading && !error && !checked && <X className="h-4 w-4 text-gray-400" />}
<Switch
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || isLoading}
/>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,105 @@
'use client'
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
label: string
description: string
icon: React.ReactNode
href: string
}
interface SettingsSearchProps {
sections: Section[]
onFilter: (filteredSections: Section[]) => void
placeholder?: string
className?: string
}
export function SettingsSearch({
sections,
onFilter,
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)
return
}
const queryLower = query.toLowerCase()
const filtered = sections.filter(section => {
const labelMatch = section.label.toLowerCase().includes(queryLower)
const descMatch = section.description.toLowerCase().includes(queryLower)
return labelMatch || descMatch
})
setFilteredSections(filtered)
}, [query, sections])
const handleClearSearch = () => {
setQuery('')
setFilteredSections(sections)
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClearSearch()
e.stopPropagation()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
const handleSearchChange = (value: string) => {
setQuery(value)
}
const hasResults = query.trim() && filteredSections.length < sections.length
const isEmptySearch = query.trim() && filteredSections.length === 0
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) => handleSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="pl-10"
autoFocus
/>
{hasResults && (
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
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">{t('settings.searchNoResults')}</p>
</div>
)}
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,87 @@
'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'
import { useLanguage } from '@/lib/i18n'
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 { t } = useLanguage()
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(t('toast.saved'))
setTimeout(() => setIsSaved(false), 2000)
} catch (err) {
console.error('Error updating setting:', err)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<input
type={type}
value={value}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100',
'placeholder:text-gray-400 dark:placeholder:text-gray-600'
)}
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
{isSaved && !isLoading && (
<Check className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-green-500" />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
'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'
import { useLanguage } from '@/lib/i18n'
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 { t } = useLanguage()
const [isLoading, setIsLoading] = useState(false)
const handleChange = async (newValue: string) => {
setIsLoading(true)
try {
await onChange(newValue)
toast.success(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn('py-4', 'border-b last:border-0 dark:border-gray-800')}>
<Label className="font-medium text-gray-900 dark:text-gray-100 block mb-1">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{description}
</p>
)}
<div className="relative">
<select
value={value}
onChange={(e) => handleChange(e.target.value)}
disabled={disabled || isLoading}
className={cn(
'w-full px-3 py-2 border rounded-lg',
'focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'disabled:opacity-50 disabled:cursor-not-allowed',
'appearance-none bg-white dark:bg-gray-900',
'border-gray-300 dark:border-gray-700',
'text-gray-900 dark:text-gray-100'
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-500" />
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
'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'
import { useLanguage } from '@/lib/i18n'
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 { t } = useLanguage()
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(t('toast.saved'))
} catch (err) {
console.error('Error updating setting:', err)
setError(true)
toast.error(t('toast.saveFailed'))
} finally {
setIsLoading(false)
}
}
return (
<div className={cn(
'flex items-center justify-between py-4',
'border-b last:border-0 dark:border-gray-800'
)}>
<div className="flex-1 pr-4">
<Label className="font-medium text-gray-900 dark:text-gray-100 cursor-pointer">
{label}
</Label>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-gray-500" />}
{!isLoading && !error && checked && <Check className="h-4 w-4 text-green-500" />}
{!isLoading && !error && !checked && <X className="h-4 w-4 text-gray-400" />}
<Switch
checked={checked}
onCheckedChange={handleChange}
disabled={disabled || isLoading}
/>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,105 @@
'use client'
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
label: string
description: string
icon: React.ReactNode
href: string
}
interface SettingsSearchProps {
sections: Section[]
onFilter: (filteredSections: Section[]) => void
placeholder?: string
className?: string
}
export function SettingsSearch({
sections,
onFilter,
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)
return
}
const queryLower = query.toLowerCase()
const filtered = sections.filter(section => {
const labelMatch = section.label.toLowerCase().includes(queryLower)
const descMatch = section.description.toLowerCase().includes(queryLower)
return labelMatch || descMatch
})
setFilteredSections(filtered)
}, [query, sections])
const handleClearSearch = () => {
setQuery('')
setFilteredSections(sections)
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClearSearch()
e.stopPropagation()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
const handleSearchChange = (value: string) => {
setQuery(value)
}
const hasResults = query.trim() && filteredSections.length < sections.length
const isEmptySearch = query.trim() && filteredSections.length === 0
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) => handleSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="pl-10"
autoFocus
/>
{hasResults && (
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 text-gray-400 hover:text-gray-600"
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">{t('settings.searchNoResults')}</p>
</div>
)}
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,222 @@
'use client'
import Link from 'next/link'
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
Lightbulb,
Bell,
Archive,
Trash2,
Plus,
Sparkles,
X,
Tag,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useLanguage } from '@/lib/i18n'
import { NotebooksList } from './notebooks-list'
import { useHomeViewOptional } from '@/context/home-view-context'
import { useEffect, useState } from 'react'
import { getTrashCount } from '@/app/actions/notes'
const HIDDEN_ROUTES = ['/agents', '/chat', '/lab']
export function Sidebar({ className, user }: { className?: string, user?: any }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const homeBridge = useHomeViewOptional()
const [trashCount, setTrashCount] = useState(0)
// Fetch trash count
useEffect(() => {
getTrashCount().then(setTrashCount)
}, [pathname, searchParams])
// Hide sidebar on Agents, Chat IA and Lab routes
if (HIDDEN_ROUTES.some(r => pathname.startsWith(r))) return null
// Active label filter
const activeLabel = searchParams.get('label')
const activeLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const clearLabelFilter = () => {
const params = new URLSearchParams(searchParams)
params.delete('label')
router.push(`/?${params.toString()}`)
}
const clearLabelsFilter = (labelToRemove?: string) => {
const params = new URLSearchParams(searchParams)
if (labelToRemove) {
const remaining = activeLabels.filter(l => l !== labelToRemove)
if (remaining.length > 0) {
params.set('labels', remaining.join(','))
} else {
params.delete('labels')
}
} else {
params.delete('labels')
}
router.push(`/?${params.toString()}`)
}
// Helper to determine if a link is active
const isActive = (href: string, exact = false) => {
if (href === '/') {
// Home is active only if no special filters are applied
return pathname === '/' &&
!searchParams.get('label') &&
!searchParams.get('archived') &&
!searchParams.get('trashed')
}
// For labels
if (href.startsWith('/?label=')) {
const labelParam = searchParams.get('label')
// Extract label from href
const labelFromHref = href.split('=')[1]
return labelParam === labelFromHref
}
// For other routes
return pathname === href
}
const NavItem = ({ href, icon: Icon, label, active, badge }: any) => (
<Link
href={href}
className={cn(
"flex items-center gap-4 px-6 py-3 rounded-e-full me-2 transition-colors",
"text-sm font-medium tracking-wide",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
: "text-muted-foreground hover:bg-muted/50 dark:hover:bg-muted/30"
)}
>
<Icon className={cn("w-5 h-5", active ? "fill-current" : "")} />
<span className="truncate">{label}</span>
{badge > 0 && (
<span className={cn(
"ms-auto text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[20px] text-center",
active
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground"
)}>
{badge}
</span>
)}
</Link>
)
return (
<aside className={cn(
"w-[280px] flex-none flex-col bg-white dark:bg-[#1e2128] overflow-y-auto hidden md:flex py-2",
className
)}>
{/* Main Navigation */}
<div className="flex flex-col gap-1 px-3">
<NavItem
href="/"
icon={Lightbulb}
label={t('sidebar.notes') || 'Notes'}
active={isActive('/')}
/>
<NavItem
href="/reminders"
icon={Bell}
label={t('sidebar.reminders') || 'Rappels'}
active={isActive('/reminders')}
/>
{pathname === '/' && homeBridge?.controls?.isTabsMode && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
className="w-full justify-start gap-3 rounded-e-full ps-4 pe-3 font-medium shadow-sm"
onClick={() => homeBridge.controls?.openNoteComposer()}
>
<Plus className="h-5 w-5 shrink-0" />
<span className="truncate">{t('sidebar.newNoteTabs')}</span>
<Sparkles className="ms-auto h-4 w-4 shrink-0 text-primary" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px]">
{t('sidebar.newNoteTabsHint')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Notebooks Section */}
<div className="flex flex-col mt-2">
<NotebooksList />
</div>
{/* Active Label Filter Chips */}
{pathname === '/' && (activeLabel || activeLabels.length > 0) && (
<div className="px-4 pt-2 flex flex-col gap-1">
{activeLabel && (
<div className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground">
<Tag className="w-3.5 h-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{activeLabel}</span>
<button
type="button"
onClick={clearLabelFilter}
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
title={t('sidebar.clearFilter') || 'Remove filter'}
>
<X className="w-3 h-3" />
</button>
</div>
)}
{activeLabels.map((label) => (
<div
key={label}
className="flex items-center gap-2 ps-2 pe-1 py-1.5 rounded-e-full me-2 bg-primary/10 dark:bg-primary/20 text-primary dark:text-primary-foreground"
>
<Tag className="w-3.5 h-3.5 shrink-0" />
<span className="text-xs font-medium truncate flex-1">{label}</span>
<button
type="button"
onClick={() => clearLabelsFilter(label)}
className="shrink-0 p-0.5 rounded-full hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors"
title={t('sidebar.clearFilter') || 'Remove filter'}
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* Archive & Trash */}
<div className="flex flex-col mt-2 border-t border-transparent">
<NavItem
href="/archive"
icon={Archive}
label={t('sidebar.archive') || 'Archives'}
active={pathname === '/archive'}
/>
<NavItem
href="/trash"
icon={Trash2}
label={t('sidebar.trash') || 'Corbeille'}
active={pathname === '/trash'}
badge={trashCount}
/>
</div>
</aside>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useEffect } from 'react'
interface ThemeInitializerProps {
theme?: string
fontSize?: string
}
export function ThemeInitializer({ theme, fontSize }: ThemeInitializerProps) {
useEffect(() => {
// Helper to apply theme
const applyTheme = (t?: string) => {
if (!t) return
const root = document.documentElement
// Reset
root.removeAttribute('data-theme')
root.classList.remove('dark')
if (t === 'auto') {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (systemDark) root.classList.add('dark')
} else if (t === 'dark') {
root.classList.add('dark')
} else if (t === 'light') {
// Default, nothing needed usually if light is default, but ensuring no 'dark' class
} else {
// Named theme
root.setAttribute('data-theme', t)
// Check if theme implies dark mode (e.g. midnight)
if (['midnight'].includes(t)) {
root.classList.add('dark')
}
}
}
// Helper to apply font size
const applyFontSize = (s?: string) => {
const size = s || 'medium'
const fontSizeMap: Record<string, string> = {
'small': '14px',
'medium': '16px',
'large': '18px',
'extra-large': '20px'
}
const fontSizeFactorMap: Record<string, number> = {
'small': 0.95,
'medium': 1.0,
'large': 1.1,
'extra-large': 1.25
}
const root = document.documentElement
root.style.setProperty('--user-font-size', fontSizeMap[size] || '16px')
root.style.setProperty('--user-font-size-factor', (fontSizeFactorMap[size] || 1).toString())
}
// CRITICAL: Use localStorage as the source of truth (it's always fresh)
// Server prop may be stale due to caching.
const localTheme = localStorage.getItem('theme-preference')
const effectiveTheme = localTheme || theme
applyTheme(effectiveTheme)
// Only sync to localStorage if it was empty (first visit after login)
// NEVER overwrite with server value if localStorage already has a value
if (!localTheme && theme) {
localStorage.setItem('theme-preference', theme)
}
applyFontSize(fontSize)
}, [theme, fontSize])
return null
}

View File

@@ -0,0 +1,62 @@
import { TitleSuggestion } from '@/hooks/use-title-suggestions'
import { Sparkles, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
interface TitleSuggestionsProps {
suggestions: TitleSuggestion[]
onSelect: (title: string) => void
onDismiss: () => void
}
export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSuggestionsProps) {
const { t } = useLanguage()
if (suggestions.length === 0) return null
return (
<div className="mt-2 p-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg animate-in fade-in slide-in-from-top-2 duration-300">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm font-medium text-amber-900 dark:text-amber-100">
<Sparkles className="w-4 h-4" />
<span>{t('titleSuggestions.title')}</span>
</div>
<button
type="button"
onClick={onDismiss}
className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-1">
{suggestions.map((suggestion, index) => (
<button
key={index}
type="button"
onClick={() => onSelect(suggestion.title)}
className={cn(
"w-full text-left px-3 py-2 rounded-md transition-all",
"hover:bg-amber-100 dark:hover:bg-amber-900",
"text-sm text-amber-900 dark:text-amber-100",
"border border-transparent hover:border-amber-300 dark:hover:border-amber-700"
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium">{suggestion.title}</span>
<span className="text-xs text-amber-600 dark:text-amber-400 whitespace-nowrap">
{suggestion.confidence}%
</span>
</div>
{suggestion.reasoning && (
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
{suggestion.reasoning}
</p>
)}
</button>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useLanguage } from '@/lib/i18n'
import { emptyTrash } from '@/app/actions/notes'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
interface TrashHeaderProps {
noteCount?: number
}
export function TrashHeader({ noteCount = 0 }: TrashHeaderProps) {
const { t } = useLanguage()
const router = useRouter()
const [showEmptyDialog, setShowEmptyDialog] = useState(false)
const [isEmptying, setIsEmptying] = useState(false)
const handleEmptyTrash = async () => {
setIsEmptying(true)
try {
await emptyTrash()
toast.success(t('trash.emptyTrashSuccess'))
router.refresh()
} catch (error) {
console.error('Error emptying trash:', error)
} finally {
setIsEmptying(false)
setShowEmptyDialog(false)
}
}
return (
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">{t('nav.trash')}</h1>
{noteCount > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => setShowEmptyDialog(true)}
disabled={isEmptying}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('trash.emptyTrash')}
</Button>
)}
<AlertDialog open={showEmptyDialog} onOpenChange={setShowEmptyDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('trash.emptyTrash')}</AlertDialogTitle>
<AlertDialogDescription>
{t('trash.emptyTrashConfirm')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleEmptyTrash}>
{t('trash.emptyTrash')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

Some files were not shown because too many files have changed in this diff Show More