refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
@@ -9,7 +9,7 @@ export function AdminContentArea({ children, className }: AdminContentAreaProps)
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
'flex-1 bg-gray-50 dark:bg-zinc-950 p-6 overflow-auto',
|
||||
'flex-1 bg-gray-50 dark:bg-zinc-950 p-6',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
499
keep-notes/components/agents/agent-form.tsx
Normal file
499
keep-notes/components/agents/agent-form.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
'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-slate-300 hover:text-slate-500 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'],
|
||||
}
|
||||
|
||||
// --- 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-white 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-slate-100">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="text-lg font-semibold text-slate-800 bg-transparent border-none outline-none focus:ring-0 p-0 flex-1 placeholder:text-slate-300"
|
||||
placeholder={t('agents.form.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
<button onClick={onCancel} className="p-1 rounded-md hover:bg-slate-100 ml-3">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
{/* Agent Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{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'
|
||||
: 'border-slate-200 text-slate-600 hover:border-slate-300'}
|
||||
`}
|
||||
>
|
||||
<div className="font-medium">{t(at.labelKey)}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{t(at.descKey)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Research Topic (researcher only) — replaces Description for this type */}
|
||||
{type === 'researcher' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.researchTopic')}<FieldHelp tooltip={t('agents.help.tooltips.researchTopic')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t('agents.form.researchTopicPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description (for non-researcher types) */}
|
||||
{type !== 'researcher' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.description')}<FieldHelp tooltip={t('agents.help.tooltips.description')} /></label>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
placeholder={t('agents.form.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URLs (scraper and custom only — researcher uses search, not URLs) */}
|
||||
{(type === 'scraper' || type === 'custom') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
{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="flex-1 px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
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 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="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.sourceNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.sourceNotebook')} /></label>
|
||||
<select
|
||||
value={sourceNotebookId}
|
||||
onChange={e => setSourceNotebookId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
>
|
||||
<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="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.targetNotebook')}<FieldHelp tooltip={t('agents.help.tooltips.targetNotebook')} /></label>
|
||||
<select
|
||||
value={targetNotebookId}
|
||||
onChange={e => setTargetNotebookId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
>
|
||||
<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="block text-sm font-medium text-slate-700 mb-1.5">{t('agents.form.frequency')}<FieldHelp tooltip={t('agents.help.tooltips.frequency')} /></label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={e => setFrequency(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white"
|
||||
>
|
||||
<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'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Mail className={`w-4 h-4 flex-shrink-0 ${notifyEmail ? 'text-primary' : 'text-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700">{t('agents.form.notifyEmail')}</div>
|
||||
<div className="text-xs text-slate-400">{t('agents.form.notifyEmailHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${notifyEmail ? 'bg-primary' : 'bg-slate-200'}`}>
|
||||
<div className={`w-4 h-4 bg-white 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'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<ImageIcon className={`w-4 h-4 flex-shrink-0 ${includeImages ? 'text-primary' : 'text-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700">{t('agents.form.includeImages')}</div>
|
||||
<div className="text-xs text-slate-400">{t('agents.form.includeImagesHint')}</div>
|
||||
</div>
|
||||
<div className={`w-9 h-5 rounded-full transition-colors flex-shrink-0 ${includeImages ? 'bg-primary' : 'bg-slate-200'}`}>
|
||||
<div className={`w-4 h-4 bg-white 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-slate-500 hover:text-slate-700 font-medium w-full pt-2 border-t border-slate-100"
|
||||
>
|
||||
{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-slate-700 mb-1">
|
||||
{t('agents.form.instructions')}
|
||||
<FieldHelp tooltip={t('agents.help.tooltips.instructions')} />
|
||||
<span className="text-xs text-slate-400 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-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-y min-h-[80px]"
|
||||
placeholder={t('agents.form.instructionsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced: Tools */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 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'
|
||||
: 'border-slate-200 text-slate-600 hover:border-slate-300'}
|
||||
`}
|
||||
>
|
||||
<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-500 bg-amber-50 px-1.5 py-0.5 rounded-full">{t('agents.tools.configNeeded')}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedTools.length > 0 && (
|
||||
<p className="text-xs text-slate-400 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-slate-700 mb-1.5">
|
||||
{t('agents.tools.maxSteps')}<FieldHelp tooltip={t('agents.help.tooltips.maxSteps')} />
|
||||
<span className="text-slate-400 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-slate-400">
|
||||
<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-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{t('agents.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm font-medium text-white 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>
|
||||
)
|
||||
}
|
||||
96
keep-notes/components/agents/agent-help.tsx
Normal file
96
keep-notes/components/agents/agent-help.tsx
Normal 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">▸</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>
|
||||
)
|
||||
}
|
||||
170
keep-notes/components/agents/agent-run-log.tsx
Normal file
170
keep-notes/components/agents/agent-run-log.tsx
Normal 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-white 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-slate-100">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800">{t('agents.runLog.title')}</h3>
|
||||
<p className="text-xs text-slate-400">{agentName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-md hover:bg-slate-100">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</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-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && actions.length === 0 && (
|
||||
<p className="text-center text-sm text-slate-400 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 border-green-100' : ''}
|
||||
${action.status === 'failure' ? 'bg-red-50/50 border-red-100' : ''}
|
||||
${action.status === 'running' ? 'bg-blue-50/50 border-blue-100' : ''}
|
||||
${action.status === 'pending' ? 'bg-slate-50 border-slate-100' : ''}
|
||||
`}
|
||||
>
|
||||
<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-slate-400" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t(statusKeys[action.status] || action.status)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDistanceToNow(new Date(action.createdAt), { addSuffix: true, locale: dateLocale })}
|
||||
</span>
|
||||
</div>
|
||||
{action.log && (
|
||||
<p className="text-xs text-slate-500 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-slate-600">{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-slate-100 rounded px-2 py-1">
|
||||
<span className="font-mono text-primary">{tc.toolName}</span>
|
||||
<span className="text-slate-400 ml-1">
|
||||
{JSON.stringify(tc.args).substring(0, 80)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} 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'
|
||||
@@ -164,7 +165,7 @@ export function BatchOrganizationDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<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" />
|
||||
@@ -238,7 +239,10 @@ export function BatchOrganizationDialog({
|
||||
aria-label={t('ai.batchOrganization.selectAllIn', { notebook: notebook.notebookName })}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{notebook.notebookIcon}</span>
|
||||
{(() => {
|
||||
const Icon = getNotebookIcon(notebook.notebookIcon)
|
||||
return <Icon className="h-5 w-5" />
|
||||
})()}
|
||||
<span className="font-semibold">
|
||||
{notebook.notebookName}
|
||||
</span>
|
||||
|
||||
189
keep-notes/components/chat/chat-container.tsx
Normal file
189
keep-notes/components/chat/chat-container.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'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[]
|
||||
}
|
||||
|
||||
export function ChatContainer({ initialConversations, notebooks }: 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 [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 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,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleNewChat = () => {
|
||||
setCurrentId(null)
|
||||
setMessages([])
|
||||
setHistoryMessages([])
|
||||
setSelectedNotebook(undefined)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
keep-notes/components/chat/chat-input.tsx
Normal file
129
keep-notes/components/chat/chat-input.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Send, BookOpen, X } 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
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, isLoading, notebooks, currentNotebookId }: 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>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
84
keep-notes/components/chat/chat-messages.tsx
Normal file
84
keep-notes/components/chat/chat-messages.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
keep-notes/components/chat/chat-sidebar.tsx
Normal file
127
keep-notes/components/chat/chat-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export const ConnectionsBadge = memo(function ConnectionsBadge({ noteId, onClick
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs 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 transition-all duration-150 ease-out',
|
||||
'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
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 } from 'lucide-react'
|
||||
import { Sparkles, X, Search, ArrowRight, Eye, GitMerge } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n/LanguageProvider'
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ConnectionsOverlayProps {
|
||||
noteId: string
|
||||
onOpenNote?: (noteId: string) => void
|
||||
onCompareNotes?: (noteIds: string[]) => void
|
||||
onMergeNotes?: (noteIds: string[]) => void
|
||||
}
|
||||
|
||||
export function ConnectionsOverlay({
|
||||
@@ -42,7 +43,8 @@ export function ConnectionsOverlay({
|
||||
onClose,
|
||||
noteId,
|
||||
onOpenNote,
|
||||
onCompareNotes
|
||||
onCompareNotes,
|
||||
onMergeNotes
|
||||
}: ConnectionsOverlayProps) {
|
||||
const { t } = useLanguage()
|
||||
const [connections, setConnections] = useState<ConnectionData[]>([])
|
||||
@@ -256,6 +258,21 @@ export function ConnectionsOverlay({
|
||||
{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>
|
||||
)
|
||||
@@ -295,19 +312,35 @@ export function ConnectionsOverlay({
|
||||
|
||||
{/* Footer - Action */}
|
||||
<div className="px-6 py-4 border-t dark:border-zinc-700">
|
||||
<Button
|
||||
className="w-full 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>
|
||||
<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>
|
||||
|
||||
23
keep-notes/components/direction-initializer.tsx
Normal file
23
keep-notes/components/direction-initializer.tsx
Normal 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
|
||||
}
|
||||
@@ -5,16 +5,19 @@ 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, isLoading }: FavoritesSectionProps) {
|
||||
export function FavoritesSection({ pinnedNotes, onEdit, onSizeChange, isLoading }: FavoritesSectionProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const cardSizeMode = useCardSizeMode()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -68,12 +71,16 @@ export function FavoritesSection({ pinnedNotes, onEdit, isLoading }: FavoritesSe
|
||||
|
||||
{/* Collapsible Content */}
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<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>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Menu, Search, StickyNote, Tag, Moon, Sun, X, Bell, Sparkles, Grid3x3, Settings, LogOut, User, Shield, Coffee } from 'lucide-react'
|
||||
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'
|
||||
@@ -56,6 +56,8 @@ export function Header({
|
||||
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)
|
||||
|
||||
@@ -327,7 +329,64 @@ export function Header({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 justify-end gap-4 items-center">
|
||||
<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
|
||||
|
||||
@@ -13,13 +13,13 @@ 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 } from 'lucide-react'
|
||||
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 { Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, Plane, ChevronRight, Plus } from 'lucide-react'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LabelFilter } from '@/components/label-filter'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -45,7 +45,6 @@ type InitialSettings = {
|
||||
}
|
||||
|
||||
interface HomeClientProps {
|
||||
/** Notes pré-chargées côté serveur — hydratées immédiatement sans loading spinner */
|
||||
initialNotes: Note[]
|
||||
initialSettings: InitialSettings
|
||||
}
|
||||
@@ -132,6 +131,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
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)
|
||||
@@ -154,10 +158,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
: 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)
|
||||
allNotes = allNotes.filter((note: any) => note.notebookId === notebook && !note._isShared)
|
||||
} else {
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId)
|
||||
allNotes = allNotes.filter((note: any) => !note.notebookId || note._isShared)
|
||||
}
|
||||
|
||||
// Filtre labels
|
||||
@@ -177,7 +182,11 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
)
|
||||
}
|
||||
|
||||
setNotes(allNotes)
|
||||
// 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)
|
||||
}
|
||||
@@ -191,11 +200,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
// Données initiales : filtrage inbox/notebook côté client seulement
|
||||
let filtered = initialNotes
|
||||
if (notebook) {
|
||||
filtered = initialNotes.filter(n => n.notebookId === notebook)
|
||||
filtered = initialNotes.filter((n: any) => n.notebookId === notebook && !n._isShared)
|
||||
} else {
|
||||
filtered = initialNotes.filter(n => !n.notebookId)
|
||||
filtered = initialNotes.filter((n: any) => !n.notebookId || n._isShared)
|
||||
}
|
||||
setNotes(filtered)
|
||||
// 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
|
||||
@@ -203,38 +216,17 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
|
||||
const { notebooks } = useNotebooks()
|
||||
const currentNotebook = notebooks.find((n: any) => n.id === searchParams.get('notebook'))
|
||||
const [showNoteInput, setShowNoteInput] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setControls({
|
||||
isTabsMode: notesViewMode === 'tabs',
|
||||
openNoteComposer: () => setShowNoteInput(true),
|
||||
openNoteComposer: () => {},
|
||||
})
|
||||
return () => setControls(null)
|
||||
}, [notesViewMode, setControls])
|
||||
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
const ICON_MAP: Record<string, any> = {
|
||||
'folder': Folder,
|
||||
'briefcase': Briefcase,
|
||||
'document': FileText,
|
||||
'lightning': Zap,
|
||||
'chart': BarChart3,
|
||||
'globe': Globe,
|
||||
'sparkle': Sparkles,
|
||||
'book': Book,
|
||||
'heart': Heart,
|
||||
'crown': Crown,
|
||||
'music': Music,
|
||||
'building': Building2,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
return ICON_MAP[iconName] || Folder
|
||||
}
|
||||
|
||||
const handleNoteCreatedWrapper = (note: any) => {
|
||||
handleNoteCreated(note)
|
||||
setShowNoteInput(false)
|
||||
}
|
||||
|
||||
const Breadcrumbs = ({ notebookName }: { notebookName: string }) => (
|
||||
@@ -290,15 +282,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
}}
|
||||
className="border-gray-200"
|
||||
/>
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.addNote') || 'Add Note'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -340,21 +323,13 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<span className="hidden sm:inline">{t('batch.organize')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isTabs && (
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(!showNoteInput)}
|
||||
className="h-10 px-6 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground font-medium shadow-sm gap-2 transition-all"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
{t('notes.newNote')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoteInput && (
|
||||
{!isTabs && (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-in fade-in slide-in-from-top-4 duration-300',
|
||||
@@ -363,7 +338,6 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
>
|
||||
<NoteInput
|
||||
onNoteCreated={handleNoteCreatedWrapper}
|
||||
forceExpanded={true}
|
||||
fullWidth={isTabs}
|
||||
/>
|
||||
</div>
|
||||
@@ -376,6 +350,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
<FavoritesSection
|
||||
pinnedNotes={pinnedNotes}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
/>
|
||||
|
||||
{notes.filter((note) => !note.isPinned).length > 0 && (
|
||||
@@ -384,6 +359,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
viewMode={notesViewMode}
|
||||
notes={notes.filter((note) => !note.isPinned)}
|
||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||
onSizeChange={handleSizeChange}
|
||||
currentNotebookId={searchParams.get('notebook')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,8 @@ 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) {
|
||||
@@ -32,6 +33,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
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) {
|
||||
@@ -57,34 +62,21 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Prevent Excalidraw from overriding document.documentElement.dir.
|
||||
// Excalidraw internally sets `document.documentElement.dir = "ltr"` which
|
||||
// breaks the RTL layout of the parent sidebar and header.
|
||||
useEffect(() => {
|
||||
const savedDir = document.documentElement.dir || 'ltr'
|
||||
|
||||
const dirObserver = new MutationObserver(() => {
|
||||
if (document.documentElement.dir !== savedDir) {
|
||||
document.documentElement.dir = savedDir
|
||||
}
|
||||
})
|
||||
dirObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['dir'] })
|
||||
|
||||
return () => dirObserver.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 {
|
||||
// Excalidraw states are purely based on the geometric elements
|
||||
const snapshot = JSON.stringify(excalidrawElements)
|
||||
// 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' },
|
||||
@@ -100,8 +92,8 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 h-full w-full bg-slate-50 dark:bg-[#121212]" dir="ltr">
|
||||
<Excalidraw
|
||||
initialData={{ elements }}
|
||||
<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}
|
||||
|
||||
63
keep-notes/components/lab/canvas-error-boundary.tsx
Normal file
63
keep-notes/components/lab/canvas-error-boundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
27
keep-notes/components/lab/canvas-wrapper.tsx
Normal file
27
keep-notes/components/lab/canvas-wrapper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { FlaskConical, Plus, X, ChevronDown, Trash2, Layout, MoreVertical } from 'lucide-react'
|
||||
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'
|
||||
@@ -25,7 +25,7 @@ interface LabHeaderProps {
|
||||
|
||||
export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHeaderProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
const { t, language } = useLanguage()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
@@ -36,22 +36,21 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
setIsEditing(false)
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await renameCanvas(id, newName)
|
||||
toast.success(t('labHeader.renamed'))
|
||||
} catch (e) {
|
||||
toast.error(t('labHeader.renameError'))
|
||||
}
|
||||
setIsEditing(false)
|
||||
})
|
||||
|
||||
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()
|
||||
const newCanvas = await createCanvas(language)
|
||||
router.push(`/lab?id=${newCanvas.id}`)
|
||||
toast.success(t('labHeader.created'))
|
||||
} catch (e) {
|
||||
@@ -148,49 +147,44 @@ export function LabHeader({ canvases, currentCanvasId, onCreateCanvas }: LabHead
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Inline Rename */}
|
||||
<div className="ms-2 flex items-center gap-2 group">
|
||||
{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="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 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"
|
||||
<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>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isPending}
|
||||
className="h-10 rounded-xl px-4 flex items-center gap-2 shadow-lg shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all outline-none"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('labHeader.new')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
41
keep-notes/components/lab/lab-skeleton.tsx
Normal file
41
keep-notes/components/lab/lab-skeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface MarkdownContentProps {
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||
<div dir="auto" className={`prose prose-sm prose-compact dark:prose-invert max-w-none break-words ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Masonry Grid Styles — CSS columns natif (sans Muuri)
|
||||
* Layout responsive pur CSS, drag-and-drop via @dnd-kit
|
||||
* Masonry Grid — Deux modes d'affichage :
|
||||
* 1. Variable : CSS Grid avec tailles small/medium/large
|
||||
* 2. Uniform : CSS Columns masonry (comme Google Keep)
|
||||
*/
|
||||
|
||||
/* ─── Container ──────────────────────────────────── */
|
||||
@@ -9,13 +10,47 @@
|
||||
padding: 0 8px 40px 8px;
|
||||
}
|
||||
|
||||
/* ─── CSS Grid Masonry ───────────────────────────── */
|
||||
/* ═══════════════════════════════════════════════════
|
||||
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 ─────────────────────────────── */
|
||||
@@ -23,15 +58,14 @@
|
||||
break-inside: avoid;
|
||||
box-sizing: border-box;
|
||||
will-change: transform;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Notes "medium" et "large" occupent 2 colonnes si disponibles */
|
||||
.masonry-sortable-item[data-size="medium"] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="large"] {
|
||||
grid-column: span 2;
|
||||
/* ─── Note card base ─────────────────────────────── */
|
||||
.note-card {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ─── Drag overlay ───────────────────────────────── */
|
||||
@@ -43,32 +77,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Note card base ─────────────────────────────── */
|
||||
.note-card {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ─── Note size min-heights ──────────────────────── */
|
||||
.masonry-sortable-item[data-size="small"] .note-card {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="medium"] .note-card {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.masonry-sortable-item[data-size="large"] .note-card {
|
||||
min-height: 440px;
|
||||
}
|
||||
|
||||
/* ─── Transitions ────────────────────────────────── */
|
||||
.masonry-sortable-item {
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* ─── Mobile (< 480px) : 1 colonne ──────────────── */
|
||||
/* ─── Mobile (< 480px) ───────────────────────────── */
|
||||
@media (max-width: 479px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -80,24 +89,33 @@
|
||||
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 (480–767px) : 2 colonnes ─────── */
|
||||
/* ─── Small tablet (480–767px) ───────────────────── */
|
||||
@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 (768–1023px) : 2–3 colonnes ────────── */
|
||||
/* ─── Tablet (768–1023px) ────────────────────────── */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
@@ -105,7 +123,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Desktop (1024–1279px) : 3–4 colonnes ──────── */
|
||||
/* ─── Desktop (1024–1279px) ─────────────────────── */
|
||||
@media (min-width: 1024px) and (max-width: 1279px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
@@ -113,7 +131,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Large Desktop (1280px+): 4–5 colonnes ─────── */
|
||||
/* ─── Large Desktop (1280px+) ───────────────────── */
|
||||
@media (min-width: 1280px) {
|
||||
.masonry-css-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
@@ -140,4 +158,4 @@
|
||||
.masonry-sortable-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ 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';
|
||||
|
||||
@@ -36,6 +37,8 @@ const NoteEditor = dynamic(
|
||||
interface MasonryGridProps {
|
||||
notes: Note[];
|
||||
onEdit?: (note: Note, readOnly?: boolean) => void;
|
||||
onSizeChange?: (noteId: string, size: 'small' | 'medium' | 'large') => void;
|
||||
isTrashView?: boolean;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -49,6 +52,7 @@ interface SortableNoteProps {
|
||||
onDragEndNote?: () => void;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
isTrashView?: boolean;
|
||||
}
|
||||
|
||||
const SortableNoteItem = memo(function SortableNoteItem({
|
||||
@@ -59,6 +63,7 @@ const SortableNoteItem = memo(function SortableNoteItem({
|
||||
onDragEndNote,
|
||||
isDragging,
|
||||
isOverlay,
|
||||
isTrashView,
|
||||
}: SortableNoteProps) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -91,6 +96,7 @@ const SortableNoteItem = memo(function SortableNoteItem({
|
||||
onDragStart={onDragStartNote}
|
||||
onDragEnd={onDragEndNote}
|
||||
isDragging={isDragging}
|
||||
isTrashView={isTrashView}
|
||||
onSizeChange={(newSize) => onSizeChange(note.id, newSize)}
|
||||
/>
|
||||
</div>
|
||||
@@ -107,6 +113,7 @@ interface SortableGridSectionProps {
|
||||
draggedNoteId: string | null;
|
||||
onDragStartNote: (noteId: string) => void;
|
||||
onDragEndNote: () => void;
|
||||
isTrashView?: boolean;
|
||||
}
|
||||
|
||||
const SortableGridSection = memo(function SortableGridSection({
|
||||
@@ -116,6 +123,7 @@ const SortableGridSection = memo(function SortableGridSection({
|
||||
draggedNoteId,
|
||||
onDragStartNote,
|
||||
onDragEndNote,
|
||||
isTrashView,
|
||||
}: SortableGridSectionProps) {
|
||||
const ids = useMemo(() => notes.map(n => n.id), [notes]);
|
||||
|
||||
@@ -131,6 +139,7 @@ const SortableGridSection = memo(function SortableGridSection({
|
||||
onDragStartNote={onDragStartNote}
|
||||
onDragEndNote={onDragEndNote}
|
||||
isDragging={draggedNoteId === note.id}
|
||||
isTrashView={isTrashView}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -141,16 +150,28 @@ const SortableGridSection = memo(function SortableGridSection({
|
||||
// ─────────────────────────────────────────────
|
||||
// Main MasonryGrid component
|
||||
// ─────────────────────────────────────────────
|
||||
export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
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(notes);
|
||||
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]);
|
||||
@@ -172,7 +193,8 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
|
||||
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(
|
||||
@@ -225,7 +247,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="masonry-container">
|
||||
<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">
|
||||
@@ -238,6 +260,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
isTrashView={isTrashView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -256,6 +279,7 @@ export function MasonryGrid({ notes, onEdit }: MasonryGridProps) {
|
||||
draggedNoteId={draggedNoteId}
|
||||
onDragStartNote={startDrag}
|
||||
onDragEndNote={endDrag}
|
||||
isTrashView={isTrashView}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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'
|
||||
@@ -39,10 +39,15 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
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 () => {
|
||||
@@ -53,6 +58,8 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
if (data.insight) {
|
||||
setInsight(data.insight)
|
||||
// Check if user is in demo mode by looking at frequency settings
|
||||
setDemoMode(true) // If we got an insight after dismiss, assume demo mode
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to fetch insight:', error)
|
||||
@@ -61,6 +68,30 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling in demo mode after first dismiss
|
||||
useEffect(() => {
|
||||
if (isDismissed && !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])
|
||||
|
||||
const handleView = async () => {
|
||||
if (!insight) return
|
||||
|
||||
@@ -107,6 +138,11 @@ export function MemoryEchoNotification({ onOpenNote }: MemoryEchoNotificationPro
|
||||
|
||||
// Dismiss notification
|
||||
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'))
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
MoreVertical,
|
||||
Palette,
|
||||
Pin,
|
||||
Trash2,
|
||||
Users,
|
||||
Maximize2,
|
||||
FileText,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NOTE_COLORS } from "@/lib/types"
|
||||
@@ -31,6 +33,11 @@ interface NoteActionsProps {
|
||||
onSizeChange?: (size: 'small' | 'medium' | 'large') => void
|
||||
onDelete: () => void
|
||||
onShareCollaborators?: () => void
|
||||
isMarkdown?: boolean
|
||||
onToggleMarkdown?: () => void
|
||||
isTrashView?: boolean
|
||||
onRestore?: () => void
|
||||
onPermanentDelete?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -45,10 +52,49 @@ export function NoteActions({
|
||||
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)}
|
||||
@@ -79,6 +125,20 @@ export function NoteActions({
|
||||
</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>
|
||||
|
||||
@@ -20,11 +20,11 @@ import {
|
||||
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 { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, 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 } from '@/app/actions/notes'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote, updateSize, getNoteAllUsers, leaveSharedNote, removeFusedBadge, restoreNote, permanentDeleteNote, createNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow, Locale } from 'date-fns'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -48,15 +48,19 @@ import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
import { NoteActions } from './note-actions'
|
||||
import { CollaboratorDialog } from './collaborator-dialog'
|
||||
import { useCardSizeMode } from '@/hooks/use-card-size-mode'
|
||||
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'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
// Mapping of supported languages to date-fns locales
|
||||
const localeMap: Record<string, Locale> = {
|
||||
@@ -81,28 +85,6 @@ 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
|
||||
@@ -112,6 +94,7 @@ interface NoteCardProps {
|
||||
onDragEnd?: () => void
|
||||
onResize?: () => void
|
||||
onSizeChange?: (newSize: 'small' | 'medium' | 'large') => void
|
||||
isTrashView?: boolean
|
||||
}
|
||||
|
||||
// Helper function to get initials from name
|
||||
@@ -149,22 +132,26 @@ export const NoteCard = memo(function NoteCard({
|
||||
onDragEnd,
|
||||
isDragging,
|
||||
onResize,
|
||||
onSizeChange
|
||||
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 [showPermanentDeleteDialog, setShowPermanentDeleteDialog] = 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
|
||||
@@ -198,6 +185,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isSharedNote = currentUserId && note.userId && currentUserId !== note.userId
|
||||
const isOwner = currentUserId && note.userId && currentUserId === note.userId
|
||||
|
||||
// Card size mode from settings
|
||||
const cardSizeMode = useCardSizeMode()
|
||||
const isUniformMode = cardSizeMode === 'uniform'
|
||||
|
||||
// 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
|
||||
@@ -281,26 +272,16 @@ export const NoteCard = memo(function NoteCard({
|
||||
})
|
||||
}
|
||||
|
||||
const handleSizeChange = async (size: 'small' | 'medium' | 'large') => {
|
||||
startTransition(async () => {
|
||||
// Instant visual feedback for the card itself
|
||||
addOptimisticNote({ size })
|
||||
const handleSizeChange = (size: 'small' | 'medium' | 'large') => {
|
||||
// Notifier le parent immédiatement (hors transition) — c'est lui
|
||||
// qui détient la source de vérité via localNotes
|
||||
onSizeChange?.(size)
|
||||
onResize?.()
|
||||
|
||||
// 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);
|
||||
}
|
||||
})
|
||||
// Persister en arrière-plan
|
||||
updateSize(note.id, size).catch(err =>
|
||||
console.error('Failed to update note size:', err)
|
||||
)
|
||||
}
|
||||
|
||||
const handleCheckItem = async (checkItemId: string) => {
|
||||
@@ -327,6 +308,27 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
try {
|
||||
await restoreNote(note.id)
|
||||
setIsDeleting(true) // Hide the note from trash view
|
||||
toast.success(t('trash.noteRestored'))
|
||||
} catch (error) {
|
||||
console.error('Failed to restore note:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermanentDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await permanentDeleteNote(note.id)
|
||||
toast.success(t('trash.notePermanentlyDeleted'))
|
||||
} catch (error) {
|
||||
console.error('Failed to permanently delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFusedBadge = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent opening the note editor
|
||||
startTransition(async () => {
|
||||
@@ -353,10 +355,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
data-testid="note-card"
|
||||
data-draggable="true"
|
||||
data-note-id={note.id}
|
||||
data-size={optimisticNote.size}
|
||||
style={{ minHeight: getMinHeight(optimisticNote.size) }}
|
||||
draggable={true}
|
||||
data-size={isUniformMode ? 'small' : optimisticNote.size}
|
||||
style={{ minHeight: isUniformMode ? 'auto' : getMinHeight(optimisticNote.size) }}
|
||||
draggable={!isTrashView}
|
||||
onDragStart={(e) => {
|
||||
if (isTrashView) return
|
||||
e.dataTransfer.setData('text/plain', note.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/html', '') // Prevent ghost image in some browsers
|
||||
@@ -382,7 +385,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Drag Handle - Only visible on mobile/touch devices */}
|
||||
{/* Drag Handle - Only visible on mobile/touch devices, not in trash */}
|
||||
{!isTrashView && (
|
||||
<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'}
|
||||
@@ -390,8 +394,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Move to Notebook Dropdown Menu */}
|
||||
{/* Move to Notebook Dropdown Menu - Hidden in trash */}
|
||||
{!isTrashView && (
|
||||
<div onClick={(e) => e.stopPropagation()} className="absolute top-2 right-2 z-20">
|
||||
<DropdownMenu open={showNotebookMenu} onOpenChange={setShowNotebookMenu}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -427,8 +433,10 @@ export const NoteCard = memo(function NoteCard({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pin Button - Visible on hover or if pinned */}
|
||||
{/* Pin Button - Visible on hover or if pinned, hidden in trash */}
|
||||
{!isTrashView && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -446,6 +454,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
className={cn("h-4 w-4", optimisticNote.isPinned ? "fill-current text-primary" : "text-muted-foreground")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
@@ -602,19 +611,22 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar Component - Always show for now to fix regression */}
|
||||
{true && (
|
||||
{/* Action Bar Component - hide destructive actions for shared notes */}
|
||||
{!isSharedNote && (
|
||||
<NoteActions
|
||||
isPinned={optimisticNote.isPinned}
|
||||
isArchived={optimisticNote.isArchived}
|
||||
currentColor={optimisticNote.color}
|
||||
currentSize={optimisticNote.size as 'small' | 'medium' | 'large'}
|
||||
currentSize={isUniformMode ? 'small' : (optimisticNote.size as 'small' | 'medium' | 'large')}
|
||||
onTogglePin={handleTogglePin}
|
||||
onToggleArchive={handleToggleArchive}
|
||||
onColorChange={handleColorChange}
|
||||
onSizeChange={handleSizeChange}
|
||||
onSizeChange={isUniformMode ? undefined : handleSizeChange}
|
||||
onDelete={() => setShowDeleteDialog(true)}
|
||||
onShareCollaborators={() => setShowCollaboratorDialog(true)}
|
||||
isTrashView={isTrashView}
|
||||
onRestore={handleRestore}
|
||||
onPermanentDelete={() => setShowPermanentDeleteDialog(true)}
|
||||
className="absolute bottom-0 left-0 right-0 p-2 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
@@ -638,13 +650,25 @@ export const NoteCard = memo(function NoteCard({
|
||||
isOpen={showConnectionsOverlay}
|
||||
onClose={() => setShowConnectionsOverlay(false)}
|
||||
noteId={note.id}
|
||||
onOpenNote={(noteId) => {
|
||||
// Find the note and open it
|
||||
onEdit?.(note, false)
|
||||
onOpenNote={(connNoteId) => {
|
||||
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>
|
||||
|
||||
@@ -665,6 +689,38 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes && fusionNotes.length > 0 && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<FusionModal
|
||||
isOpen={!!fusionNotes}
|
||||
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,
|
||||
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'))
|
||||
triggerRefresh()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
@@ -682,6 +738,24 @@ export const NoteCard = memo(function NoteCard({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Permanent Delete Confirmation Dialog (Trash view only) */}
|
||||
<AlertDialog open={showPermanentDeleteDialog} onOpenChange={setShowPermanentDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('trash.permanentDelete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('trash.permanentDeleteConfirm')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel') || 'Cancel'}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handlePermanentDelete}>
|
||||
{t('trash.permanentDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2 } from 'lucide-react'
|
||||
import { updateNote, createNote } from '@/app/actions/notes'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
|
||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
@@ -66,6 +66,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
const [color, setColor] = useState(note.color)
|
||||
const [size, setSize] = useState<NoteSize>(note.size || 'small')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
||||
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -175,7 +176,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
}
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
const removedUrl = images[index]
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
// Track removed images for cleanup on save
|
||||
if (removedUrl) {
|
||||
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLink = async () => {
|
||||
@@ -483,6 +489,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
size,
|
||||
})
|
||||
|
||||
// Clean up removed image files from disk (best-effort, don't block save)
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
}
|
||||
|
||||
// Rafraîchir les labels globaux pour refléter les suppressions éventuelles (orphans)
|
||||
await refreshLabels()
|
||||
|
||||
@@ -989,6 +1000,23 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
||||
<Copy className="h-4 w-4" />
|
||||
{t('notes.makeCopy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{t('notes.leaveShare')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t('general.close')}
|
||||
</Button>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
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 {
|
||||
@@ -24,6 +27,9 @@ import {
|
||||
toggleArchive,
|
||||
updateColor,
|
||||
deleteNote,
|
||||
removeImageFromNote,
|
||||
leaveSharedNote,
|
||||
createNote,
|
||||
} from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import {
|
||||
@@ -49,6 +55,8 @@ import {
|
||||
RotateCcw,
|
||||
Languages,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
@@ -58,6 +66,7 @@ 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'
|
||||
@@ -99,8 +108,11 @@ export function NoteInlineEditor({
|
||||
defaultPreviewMode = false,
|
||||
}: NoteInlineEditorProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { labels: globalLabels, addLabel } = useLabels()
|
||||
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
||||
const [, startTransition] = useTransition()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const isSharedNote = !!(note as any)._isShared
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
@@ -113,11 +125,41 @@ export function NoteInlineEditor({
|
||||
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 }) }
|
||||
|
||||
// Textarea ref for formatting toolbar
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const applyFormat = (prefix: string, suffix: string = prefix) => {
|
||||
const textarea = textAreaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = content.substring(start, end)
|
||||
const before = content.substring(0, start)
|
||||
const after = content.substring(end)
|
||||
|
||||
const newContent = before + prefix + selected + suffix + after
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
|
||||
// Restore cursor position after React re-renders
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
const newCursorPos = selected ? end + prefix.length + suffix.length : start + prefix.length
|
||||
textarea.setSelectionRange(
|
||||
selected ? start + prefix.length : start + prefix.length,
|
||||
selected ? end + prefix.length : newCursorPos
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Link dialog
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
@@ -230,12 +272,103 @@ export function NoteInlineEditor({
|
||||
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 {}
|
||||
try {
|
||||
await addLabel(tag)
|
||||
// Refresh labels to get the new color assignment
|
||||
await refreshLabels()
|
||||
} catch {}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLabel = async (label: string) => {
|
||||
const newLabels = (note.labels || []).filter((l) => l !== label)
|
||||
// Optimistic UI
|
||||
onChange?.(note.id, { labels: newLabels })
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
toast.success(t('labels.labelRemoved', { label }))
|
||||
}
|
||||
|
||||
// ── Shared note actions ────────────────────────────────────────────────────
|
||||
const handleMakeCopy = async () => {
|
||||
try {
|
||||
await createNote({
|
||||
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||
content,
|
||||
color: note.color,
|
||||
type: note.type,
|
||||
checkItems: note.checkItems ?? undefined,
|
||||
labels: note.labels ?? undefined,
|
||||
images: note.images ?? undefined,
|
||||
links: note.links ?? undefined,
|
||||
isMarkdown,
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
triggerRefresh()
|
||||
} catch (error) {
|
||||
toast.error(t('notes.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
onDelete?.(note.id)
|
||||
} catch (error) {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMergeNotes = 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 }
|
||||
}))
|
||||
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
const handleCompareNotes = 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 }
|
||||
}))
|
||||
setComparisonNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
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,
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
triggerRefresh()
|
||||
}
|
||||
|
||||
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
||||
const handleTogglePin = () => {
|
||||
startTransition(async () => {
|
||||
@@ -262,10 +395,21 @@ export function NoteInlineEditor({
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(t('notes.confirmDelete'))) return
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
toast(t('notes.confirmDelete'), {
|
||||
action: {
|
||||
label: t('notes.delete'),
|
||||
onClick: () => {
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
})
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t('common.cancel'),
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -293,7 +437,7 @@ export function NoteInlineEditor({
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
await removeImageFromNote(note.id, index)
|
||||
}
|
||||
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
@@ -437,7 +581,27 @@ export function NoteInlineEditor({
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
|
||||
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
|
||||
{/* ── Shared note banner ──────────────────────────────────────────── */}
|
||||
{isSharedNote && (
|
||||
<div className="flex items-center justify-between border-b border-border/30 bg-primary/5 dark:bg-primary/10 px-4 py-2">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{t('notes.sharedReadOnly') || 'Lecture seule — note partagée'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="default" size="sm" className="h-7 gap-1.5 text-xs" onClick={handleMakeCopy}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t('notes.makeCopy') || 'Copier'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30" onClick={handleLeaveShare}>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t('notes.leaveShare') || 'Quitter'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Toolbar (hidden for shared notes) ────────────────────────────── */}
|
||||
{!isSharedNote && (
|
||||
<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 */}
|
||||
@@ -625,12 +789,6 @@ export function NoteInlineEditor({
|
||||
)}
|
||||
</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>
|
||||
@@ -677,6 +835,7 @@ export function NoteInlineEditor({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
||||
{showLinkInput && (
|
||||
@@ -704,7 +863,11 @@ export function NoteInlineEditor({
|
||||
<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} />
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => handleRemoveLabel(label)}
|
||||
/>
|
||||
))}
|
||||
{/* AI-suggested tags inline with labels */}
|
||||
<GhostTags
|
||||
@@ -728,6 +891,7 @@ export function NoteInlineEditor({
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
/>
|
||||
{/* AI title suggestion — show when title is empty and there's content */}
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
@@ -812,17 +976,21 @@ export function NoteInlineEditor({
|
||||
<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' }}
|
||||
/>
|
||||
<>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
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() }}
|
||||
readOnly={isSharedNote}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
||||
@@ -881,6 +1049,15 @@ export function NoteInlineEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section (not for shared notes) ── */}
|
||||
{!isSharedNote && (
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||
@@ -891,6 +1068,25 @@ export function NoteInlineEditor({
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function NoteInput({
|
||||
const [showCollaboratorDialog, setShowCollaboratorDialog] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Simple state without complex undo/redo - like Google Keep
|
||||
// Simple state without complex undo/redo
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -121,8 +122,12 @@ export function NotebookSuggestionToast({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{t('notebookSuggestion.title', { icon: suggestion.icon, name: suggestion.name })}
|
||||
<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')}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LucideIcon, Plane, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
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'
|
||||
@@ -17,28 +17,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { LabelManagementDialog } from '@/components/label-management-dialog'
|
||||
import { Notebook } from '@/lib/types'
|
||||
|
||||
// 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,
|
||||
'flight_takeoff': Plane,
|
||||
}
|
||||
|
||||
// Function to get icon component by name
|
||||
const getNotebookIcon = (iconName: string) => {
|
||||
return ICON_MAP[iconName] || Folder
|
||||
}
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
|
||||
export function NotebooksList() {
|
||||
const pathname = usePathname()
|
||||
|
||||
@@ -23,10 +23,11 @@ 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, currentNotebookId }: NotesMainSectionProps) {
|
||||
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">
|
||||
@@ -37,7 +38,7 @@ export function NotesMainSection({ notes, viewMode, onEdit, currentNotebookId }:
|
||||
|
||||
return (
|
||||
<div data-testid="notes-grid">
|
||||
<MasonryGridLazy notes={notes} onEdit={onEdit} />
|
||||
<MasonryGridLazy notes={notes} onEdit={onEdit} onSizeChange={onSizeChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ 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 } from '@/app/actions/notes'
|
||||
import { updateFullOrderWithoutRevalidation, createNote, deleteNote } from '@/app/actions/notes'
|
||||
import {
|
||||
GripVertical,
|
||||
Hash,
|
||||
@@ -33,8 +33,17 @@ import {
|
||||
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'
|
||||
@@ -105,14 +114,18 @@ 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
|
||||
}) {
|
||||
@@ -231,6 +244,20 @@ function SortableNoteListItem({
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -242,6 +269,7 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
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
|
||||
@@ -254,7 +282,15 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
return prev.map((p) => {
|
||||
const fresh = notes.find((n) => n.id === p.id)
|
||||
if (!fresh) return p
|
||||
return { ...fresh, title: p.title, content: p.content, labels: p.labels }
|
||||
// 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
|
||||
@@ -386,7 +422,9 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
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')}
|
||||
/>
|
||||
@@ -430,6 +468,45 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Bell, Check, X, Clock, User } from 'lucide-react'
|
||||
import { Bell, Check, X, Clock } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getPendingShareRequests, respondToShareRequest, removeSharedNoteFromView } from '@/app/actions/notes'
|
||||
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'
|
||||
@@ -40,37 +40,40 @@ export function NotificationPanel() {
|
||||
const { t } = useLanguage()
|
||||
const [requests, setRequests] = useState<ShareRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const loadRequests = async () => {
|
||||
setIsLoading(true)
|
||||
const loadRequests = useCallback(async () => {
|
||||
try {
|
||||
const data = await getPendingShareRequests()
|
||||
setRequests(data)
|
||||
setPendingCount(data.length)
|
||||
setRequests(data as any)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load share requests:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests()
|
||||
const interval = setInterval(loadRequests, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
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))
|
||||
setPendingCount(prev => prev - 1)
|
||||
triggerRefresh()
|
||||
toast.success(t('notes.noteCreated'), {
|
||||
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'))
|
||||
@@ -81,27 +84,17 @@ export function NotificationPanel() {
|
||||
try {
|
||||
await respondToShareRequest(shareId, 'decline')
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
setPendingCount(prev => prev - 1)
|
||||
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'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (shareId: string) => {
|
||||
try {
|
||||
await removeSharedNoteFromView(shareId)
|
||||
setRequests(prev => prev.filter(r => r.id !== shareId))
|
||||
toast.info(t('notification.removed'))
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -117,8 +110,8 @@ export function NotificationPanel() {
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
</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">
|
||||
@@ -136,12 +129,11 @@ export function NotificationPanel() {
|
||||
{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" />
|
||||
{t('general.loading')}
|
||||
</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('search.noResults')}</p>
|
||||
<p className="font-medium">{t('notification.noNotifications') || 'No new notifications'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
@@ -151,7 +143,7 @@ export function NotificationPanel() {
|
||||
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">
|
||||
<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">
|
||||
@@ -162,63 +154,50 @@ export function NotificationPanel() {
|
||||
{t('notification.shared', { title: request.note.title || t('notification.untitled') })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs capitalize bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground border-0"
|
||||
>
|
||||
{request.permission}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleAccept(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
|
||||
"bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700",
|
||||
"text-white shadow-md hover:shadow-lg",
|
||||
"transition-all duration-200",
|
||||
"flex items-center justify-center gap-1.5",
|
||||
"active:scale-95"
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t('general.confirm')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDecline(request.id)}
|
||||
className={cn(
|
||||
"flex-1 h-9 px-4 text-xs font-semibold rounded-lg",
|
||||
"bg-white dark:bg-gray-800",
|
||||
"border-2 border-gray-200 dark:border-gray-700",
|
||||
"text-gray-700 dark:text-gray-300",
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
"hover:border-gray-300 dark:hover:border-gray-600",
|
||||
"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('general.cancel')}
|
||||
{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>
|
||||
<button
|
||||
onClick={() => handleRemove(request.id)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
{t('general.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { LanguageProvider } from '@/lib/i18n/LanguageProvider'
|
||||
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' }: ProvidersWrapperProps) {
|
||||
export function ProvidersWrapper({ children, initialLanguage = 'en', initialTranslations }: ProvidersWrapperProps) {
|
||||
return (
|
||||
<NoteRefreshProvider>
|
||||
<LabelProvider>
|
||||
<NotebooksProvider>
|
||||
<NotebookDragProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage as any}>
|
||||
<HomeViewProvider>{children}</HomeViewProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage as any} initialTranslations={initialTranslations}>
|
||||
<DirWrapper>
|
||||
<HomeViewProvider>{children}</HomeViewProvider>
|
||||
</DirWrapper>
|
||||
</LanguageProvider>
|
||||
</NotebookDragProvider>
|
||||
</NotebooksProvider>
|
||||
|
||||
@@ -105,17 +105,27 @@ function CompactCard({
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(t('notes.confirmDelete'))) {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteNote(note.id)
|
||||
triggerRefresh()
|
||||
router.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Lightbulb,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
X,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -21,12 +23,51 @@ import {
|
||||
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) => {
|
||||
@@ -50,7 +91,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
return pathname === href
|
||||
}
|
||||
|
||||
const NavItem = ({ href, icon: Icon, label, active }: any) => (
|
||||
const NavItem = ({ href, icon: Icon, label, active, badge }: any) => (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
@@ -63,6 +104,16 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -112,7 +163,42 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
<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">
|
||||
@@ -127,6 +213,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
|
||||
icon={Trash2}
|
||||
label={t('sidebar.trash') || 'Corbeille'}
|
||||
active={pathname === '/trash'}
|
||||
badge={trashCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
78
keep-notes/components/trash-header.tsx
Normal file
78
keep-notes/components/trash-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
keep-notes/components/ui/combobox.tsx
Normal file
125
keep-notes/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Check, ChevronDown, Search } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
interface ComboboxOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: ComboboxOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
emptyMessage?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select...',
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No results found.',
|
||||
disabled = false,
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [search, setSearch] = React.useState('')
|
||||
|
||||
const selectedLabel = options.find((o) => o.value === value)?.label
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
if (!search.trim()) return options
|
||||
const q = search.toLowerCase()
|
||||
return options.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(q) ||
|
||||
o.value.toLowerCase().includes(q)
|
||||
)
|
||||
}, [options, search])
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onChange(optionValue === value ? '' : optionValue)
|
||||
setOpen(false)
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'hover:bg-accent hover:text-accent-foreground transition-colors',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
!value && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{selectedLabel || placeholder}</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
className="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto p-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((option) => {
|
||||
const isSelected = option.value === value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(option.value)}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'hover:bg-accent hover:text-accent-foreground transition-colors',
|
||||
isSelected && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
155
keep-notes/components/ui/command.tsx
Normal file
155
keep-notes/components/ui/command.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
15
keep-notes/components/ui/skeleton.tsx
Normal file
15
keep-notes/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("bg-muted animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
Reference in New Issue
Block a user