feat: RTL/i18n, AI translate+undo, no-refresh saves, settings perf

- RTL: force dir=rtl on LabelFilter, NotesViewToggle, LabelManagementDialog
- i18n: add missing keys (notifications, privacy, edit/preview, AI translate/undo)
- Settings pages: convert to Server Components (general, appearance) + loading skeleton
- AI menu: add Translate option (10 languages) + Undo AI button in toolbar
- Fix: saveInline uses REST API instead of Server Action → eliminates all implicit refreshes in list mode
- Fix: NotesTabsView notes sync effect preserves selected note on content changes
- Fix: auto-tag suggestions now filter already-assigned labels
- Fix: color change in card view uses local state (no refresh)
- Fix: nav links use <Link> for prefetching (Settings, Admin)
- Fix: suppress duplicate label suggestions already on note
- Route: add /api/ai/translate endpoint
This commit is contained in:
Sepehr Ramezani
2026-04-15 23:48:28 +02:00
parent 39671c6472
commit b6a548acd8
68 changed files with 5014 additions and 485 deletions

View File

@@ -64,21 +64,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
}
}
const handleProviderChange = async (value: 'auto' | 'openai' | 'ollama') => {
setSettings(prev => ({ ...prev, aiProvider: value }))
try {
setIsPending(true)
await updateAISettings({ aiProvider: value })
toast.success(t('aiSettings.saved'))
} catch (error) {
console.error('Error updating provider:', error)
toast.error(t('aiSettings.error'))
setSettings(initialSettings)
} finally {
setIsPending(false)
}
}
const handleLanguageChange = async (value: 'auto' | 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl') => {
setSettings(prev => ({ ...prev, preferredLanguage: value }))
@@ -188,54 +174,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
/>
</div>
{/* AI Provider Selection */}
<Card className="p-4">
<Label className="text-base font-medium mb-1">{t('aiSettings.provider')}</Label>
<p className="text-sm text-gray-500 mb-4">
{t('aiSettings.providerDesc')}
</p>
<RadioGroup
value={settings.aiProvider}
onValueChange={handleProviderChange}
>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="auto" id="auto" />
<div className="grid gap-1.5">
<Label htmlFor="auto" className="font-medium">
{t('aiSettings.providerAuto')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerAutoDesc')}
</p>
</div>
</div>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="ollama" id="ollama" />
<div className="grid gap-1.5">
<Label htmlFor="ollama" className="font-medium">
{t('aiSettings.providerOllama')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerOllamaDesc')}
</p>
</div>
</div>
<div className="flex items-start space-x-2 py-2">
<RadioGroupItem value="openai" id="openai" />
<div className="grid gap-1.5">
<Label htmlFor="openai" className="font-medium">
{t('aiSettings.providerOpenAI')}
</Label>
<p className="text-sm text-gray-500">
{t('aiSettings.providerOpenAIDesc')}
</p>
</div>
</div>
</RadioGroup>
</Card>
</div>
)
}

View File

@@ -330,12 +330,12 @@ export function Header({
<div className="flex flex-1 justify-end gap-4 items-center">
{/* Settings Button */}
<button
onClick={() => router.push('/settings')}
<Link
href="/settings"
className="flex items-center justify-center size-10 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 transition-colors"
>
<Settings className="w-5 h-5" />
</button>
</Link>
{/* User Avatar Menu */}
<DropdownMenu>
@@ -356,13 +356,17 @@ export function Header({
{currentUser?.email && <p className="w-[200px] truncate text-sm text-muted-foreground">{currentUser.email}</p>}
</div>
</div>
<DropdownMenuItem onClick={() => router.push('/settings/profile')} className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') || 'Profile'}</span>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/settings/profile">
<User className="mr-2 h-4 w-4" />
<span>{t('settings.profile') || 'Profile'}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/admin')} className="cursor-pointer">
<Shield className="mr-2 h-4 w-4" />
<span>{t('nav.adminDashboard')}</span>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/admin">
<Shield className="mr-2 h-4 w-4" />
<span>{t('nav.adminDashboard')}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => signOut()} className="cursor-pointer text-red-600 focus:text-red-600">
<LogOut className="mr-2 h-4 w-4" />

View File

@@ -24,7 +24,7 @@ interface LabelFilterProps {
export function LabelFilter({ selectedLabels, onFilterChange, className }: LabelFilterProps) {
const { labels, loading } = useLabels()
const { t } = useLanguage()
const { t, language } = useLanguage()
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
useEffect(() => {
@@ -47,10 +47,11 @@ export function LabelFilter({ selectedLabels, onFilterChange, className }: Label
if (loading || allLabelNames.length === 0) return null
return (
<div className={cn("flex items-center gap-2", className ? "" : "")}>
<DropdownMenu>
<div dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'} className={cn("flex items-center gap-2", className ? "" : "")}>
<DropdownMenu dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
<DropdownMenuTrigger asChild>
<Button
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
variant="outline"
size="sm"
className={cn(

View File

@@ -16,6 +16,7 @@ import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { useLanguage } from '@/lib/i18n'
import { useNoteRefresh } from '@/context/NoteRefreshContext'
export interface LabelManagementDialogProps {
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
@@ -26,7 +27,9 @@ export interface LabelManagementDialogProps {
export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const { open, onOpenChange } = props
const { labels, loading, addLabel, updateLabel, deleteLabel } = useLabels()
const { t } = useLanguage()
const { t, language } = useLanguage()
const { triggerRefresh } = useNoteRefresh()
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [newLabel, setNewLabel] = useState('')
const [editingColorId, setEditingColorId] = useState<string | null>(null)
@@ -37,6 +40,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
if (trimmed) {
try {
await addLabel(trimmed, 'gray')
triggerRefresh()
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
@@ -45,18 +49,19 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
}
const handleDeleteLabel = async (id: string) => {
if (confirm(t('labels.confirmDelete'))) {
try {
await deleteLabel(id)
} catch (error) {
console.error('Failed to delete label:', error)
}
try {
await deleteLabel(id)
triggerRefresh()
setConfirmDeleteId(null)
} catch (error) {
console.error('Failed to delete label:', error)
}
}
const handleChangeColor = async (id: string, color: LabelColorName) => {
try {
await updateLabel(id, { color })
triggerRefresh()
setEditingColorId(null)
} catch (error) {
console.error('Failed to update label color:', error)
@@ -157,26 +162,38 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
)}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setEditingColorId(isEditing ? null : label.id)}
title={t('labels.changeColor')}
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => handleDeleteLabel(label.id)}
title={t('labels.deleteTooltip')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{confirmDeleteId === label.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 font-medium">{t('labels.confirmDeleteShort') || 'Confirmer ?'}</span>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setConfirmDeleteId(null)}>
{t('common.cancel') || 'Annuler'}
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={() => handleDeleteLabel(label.id)}>
{t('common.delete') || 'Supprimer'}
</Button>
</div>
) : (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => setEditingColorId(isEditing ? null : label.id)}
title={t('labels.changeColor')}
>
<Palette className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => setConfirmDeleteId(label.id)}
title={t('labels.deleteTooltip')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
)
})
@@ -188,14 +205,14 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
if (controlled) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
{dialogContent}
</Dialog>
)
}
return (
<Dialog>
<Dialog dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title={t('labels.manage')}>
<Settings className="h-5 w-5" />

View File

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

View File

@@ -169,7 +169,10 @@ export const NoteCard = memo(function NoteCard({
(state, newProps: Partial<Note>) => ({ ...state, ...newProps })
)
const colorClasses = NOTE_COLORS[optimisticNote.color as NoteColor] || NOTE_COLORS.default
// Local color state so color persists after transition ends
const [localColor, setLocalColor] = useState(note.color)
const colorClasses = NOTE_COLORS[(localColor || optimisticNote.color) as NoteColor] || NOTE_COLORS.default
// Check if this note is currently open in the editor
const isNoteOpenInEditor = searchParams.get('note') === note.id
@@ -263,10 +266,10 @@ export const NoteCard = memo(function NoteCard({
}
const handleColorChange = async (color: string) => {
setLocalColor(color) // instant visual update, survives transition
startTransition(async () => {
addOptimisticNote({ color })
await updateColor(note.id, color)
router.refresh()
await updateNote(note.id, { color }, { skipRevalidation: false })
})
}

View File

@@ -142,10 +142,11 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
}
// Filtrer les suggestions pour ne pas afficher celles rejetées par l'utilisateur
// (On garde celles déjà ajoutées pour les afficher en mode "validé")
// ni celles déjà présentes sur la note
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
return !dismissedTags.includes(s.tag)
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
})
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {

View File

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

View File

@@ -68,9 +68,16 @@ interface NoteInputProps {
onNoteCreated?: (note: Note) => void
defaultExpanded?: boolean
forceExpanded?: boolean
/** Mode onglets : occupe toute la largeur du contenu principal (plus de carte étroite centrée) */
fullWidth?: boolean
}
export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpanded = false }: NoteInputProps) {
export function NoteInput({
onNoteCreated,
defaultExpanded = false,
forceExpanded = false,
fullWidth = false,
}: NoteInputProps) {
const { labels: globalLabels, addLabel } = useLabels()
const { data: session } = useSession()
const { t } = useLanguage()
@@ -109,7 +116,8 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded
enabled: type === 'text' && isExpanded,
notebookId: currentNotebookId
})
// Title suggestions
@@ -559,11 +567,13 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
setDismissedTitleSuggestions(false)
}
const widthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
if (!isExpanded) {
return (
<Card className="p-4 max-w-2xl mx-auto mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow">
<Card className={cn('p-4 mb-8 cursor-text shadow-md hover:shadow-lg transition-shadow', widthClass)}>
<div className="flex items-center gap-4">
<Input
<Input dir="auto"
placeholder={t('notes.placeholder')}
onClick={() => setIsExpanded(true)}
readOnly
@@ -590,12 +600,9 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
return (
<>
<Card className={cn(
"p-4 max-w-2xl mx-auto mb-8 shadow-lg border",
colorClasses.card
)}>
<Card className={cn('p-4 mb-8 shadow-lg border', widthClass, colorClasses.card)}>
<div className="space-y-3">
<Input
<Input dir="auto"
placeholder={t('notes.titlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -707,7 +714,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
className="min-h-[100px] p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50"
/>
) : (
<Textarea
<Textarea dir="auto"
placeholder={isMarkdown ? t('notes.markdownPlaceholder') : t('notes.placeholder')}
value={content}
onChange={(e) => setContent(e.target.value)}
@@ -743,7 +750,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
{checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox className="mt-2" />
<Input
<Input dir="auto"
value={item.text}
onChange={(e) => handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
@@ -1015,7 +1022,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<label htmlFor="reminder-date" className="text-sm font-medium">
{t('notes.date')}
</label>
<Input
<Input dir="auto"
id="reminder-date"
type="date"
value={reminderDate}
@@ -1027,7 +1034,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<label htmlFor="reminder-time" className="text-sm font-medium">
{t('notes.time')}
</label>
<Input
<Input dir="auto"
id="reminder-time"
type="time"
value={reminderTime}
@@ -1077,7 +1084,7 @@ export function NoteInput({ onNoteCreated, defaultExpanded = false, forceExpande
<DialogTitle>{t('notes.addLink')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
<Input dir="auto"
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}

View File

@@ -44,7 +44,7 @@ export function NotebooksList() {
const pathname = usePathname()
const searchParams = useSearchParams()
const router = useRouter()
const { t } = useLanguage()
const { t, language } = useLanguage()
const { notebooks, currentNotebook, deleteNotebook, moveNoteToNotebookOptimistic, isLoading } = useNotebooks()
const { draggedNoteId, dragOverNotebookId, dragOver } = useNotebookDrag()
const { labels } = useLabels()
@@ -160,7 +160,7 @@ export function NotebooksList() {
onDragOver={(e) => handleDragOver(e, notebook.id)}
onDragLeave={handleDragLeave}
className={cn(
"flex flex-col mr-2 rounded-r-full transition-all relative",
"flex flex-col me-2 rounded-e-full transition-all relative",
!notebook.color && "bg-primary/10 dark:bg-primary/20",
isDragOver && "ring-2 ring-primary ring-dashed"
)}
@@ -211,7 +211,7 @@ export function NotebooksList() {
{isExpanded && (
<div className="flex flex-col pb-2">
{labels.length === 0 ? (
<p className="pointer-events-none pl-12 pr-4 py-2 text-xs text-muted-foreground">
<p className="pointer-events-none ps-12 pe-4 py-2 text-xs text-muted-foreground">
{t('sidebar.noLabelsInNotebook')}
</p>
) : (
@@ -221,7 +221,7 @@ export function NotebooksList() {
type="button"
onClick={() => handleLabelFilter(label.name, notebook.id)}
className={cn(
'pointer-events-auto flex items-center gap-4 pl-12 pr-4 py-2 rounded-r-full mr-2 transition-colors',
'pointer-events-auto flex items-center gap-4 ps-12 pe-4 py-2 rounded-e-full me-2 transition-colors',
'hover:bg-accent/60 text-muted-foreground hover:text-foreground',
searchParams.get('labels')?.includes(label.name) &&
'font-semibold text-foreground'
@@ -235,7 +235,7 @@ export function NotebooksList() {
<button
type="button"
onClick={() => setLabelsDialogOpen(true)}
className="pointer-events-auto flex items-center gap-2 pl-12 pr-4 py-2 mt-1 rounded-r-full mr-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
className="pointer-events-auto flex items-center gap-2 ps-12 pe-4 py-2 mt-1 rounded-e-full me-2 transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/60 group/label"
>
<Plus className="h-3 w-3 shrink-0 group-hover/label:scale-110 transition-transform" />
<span className="text-xs font-medium">{t('sidebar.editLabels')}</span>
@@ -251,25 +251,25 @@ export function NotebooksList() {
onDragLeave={handleDragLeave}
className={cn(
"flex items-center relative",
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-r-full mr-2"
isDragOver && "ring-2 ring-blue-500 ring-dashed rounded-e-full me-2"
)}
>
<button
onClick={() => handleSelectNotebook(notebook.id)}
className={cn(
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pr-24",
"pointer-events-auto flex items-center gap-4 px-6 py-3 rounded-e-full me-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors w-full pe-24",
isDragOver && "opacity-50"
)}
>
<NotebookIcon className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-left">{notebook.name}</span>
<span className="text-sm font-medium tracking-wide truncate min-w-0 text-start">{notebook.name}</span>
{(notebook as any).notesCount > 0 && (
<span className="text-xs text-gray-400 ml-2 flex-shrink-0">({(notebook as any).notesCount})</span>
<span className="text-xs text-gray-400 ms-2 flex-shrink-0">({new Intl.NumberFormat(language).format((notebook as any).notesCount)})</span>
)}
</button>
{/* Actions + expand on the right — always rendered, visible on hover */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<div className="absolute end-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<NotebookActions
notebook={notebook}
onEdit={() => setEditingNotebook(notebook)}

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ 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'
interface ProvidersWrapperProps {
@@ -19,7 +20,7 @@ export function ProvidersWrapper({ children, initialLanguage = 'en' }: Providers
<NotebooksProvider>
<NotebookDragProvider>
<LanguageProvider initialLanguage={initialLanguage as any}>
{children}
<HomeViewProvider>{children}</HomeViewProvider>
</LanguageProvider>
</NotebookDragProvider>
</NotebooksProvider>

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Settings, Sparkles, Palette, User, Database, Info, Check } from 'lucide-react'
import { Settings, Sparkles, Palette, User, Database, Info, Check, Key } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
@@ -52,6 +52,12 @@ export function SettingsNav({ className }: SettingsNavProps) {
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'),

View File

@@ -8,14 +8,25 @@ import {
Bell,
Archive,
Trash2,
Plus,
Sparkles,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useLanguage } from '@/lib/i18n'
import { NotebooksList } from './notebooks-list'
import { useHomeViewOptional } from '@/context/home-view-context'
export function Sidebar({ className, user }: { className?: string, user?: any }) {
const pathname = usePathname()
const searchParams = useSearchParams()
const { t } = useLanguage()
const homeBridge = useHomeViewOptional()
// Helper to determine if a link is active
const isActive = (href: string, exact = false) => {
@@ -43,7 +54,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
<Link
href={href}
className={cn(
"flex items-center gap-4 px-6 py-3 rounded-r-full mr-2 transition-colors",
"flex items-center gap-4 px-6 py-3 rounded-e-full me-2 transition-colors",
"text-sm font-medium tracking-wide",
active
? "bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary-foreground"
@@ -61,7 +72,7 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
className
)}>
{/* Main Navigation */}
<div className="flex flex-col">
<div className="flex flex-col gap-1 px-3">
<NavItem
href="/"
icon={Lightbulb}
@@ -74,6 +85,26 @@ export function Sidebar({ className, user }: { className?: string, user?: any })
label={t('sidebar.reminders') || 'Rappels'}
active={isActive('/reminders')}
/>
{pathname === '/' && homeBridge?.controls?.isTabsMode && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
className="w-full justify-start gap-3 rounded-e-full ps-4 pe-3 font-medium shadow-sm"
onClick={() => homeBridge.controls?.openNoteComposer()}
>
<Plus className="h-5 w-5 shrink-0" />
<span className="truncate">{t('sidebar.newNoteTabs')}</span>
<Sparkles className="ml-auto h-4 w-4 shrink-0 text-primary" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[240px]">
{t('sidebar.newNoteTabsHint')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{/* Notebooks Section */}

View File

@@ -22,6 +22,7 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
<span>{t('titleSuggestions.title')}</span>
</div>
<button
type="button"
onClick={onDismiss}
className="text-amber-600 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 transition-colors"
>
@@ -33,6 +34,7 @@ export function TitleSuggestions({ suggestions, onSelect, onDismiss }: TitleSugg
{suggestions.map((suggestion, index) => (
<button
key={index}
type="button"
onClick={() => onSelect(suggestion.title)}
className={cn(
"w-full text-left px-3 py-2 rounded-md transition-all",