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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
478
keep-notes/components/mcp/mcp-settings-panel.tsx
Normal file
478
keep-notes/components/mcp/mcp-settings-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
896
keep-notes/components/note-inline-editor.tsx
Normal file
896
keep-notes/components/note-inline-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
43
keep-notes/components/notes-main-section.tsx
Normal file
43
keep-notes/components/notes-main-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
435
keep-notes/components/notes-tabs-view.tsx
Normal file
435
keep-notes/components/notes-tabs-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
89
keep-notes/components/notes-view-toggle.tsx
Normal file
89
keep-notes/components/notes-view-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user