feat: standardize UI theme, fix dark mode consistency, and implement editorial tags
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m24s

This commit is contained in:
Antigravity
2026-05-10 18:43:13 +00:00
parent f6880bd0e1
commit 330c0c61b6
25 changed files with 640 additions and 503 deletions

View File

@@ -31,12 +31,12 @@ export default async function MainLayout({
return ( return (
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}> <ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */} {/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-memento-desk"> <div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}> <Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
<Sidebar user={session?.user} /> <Sidebar user={session?.user} />
</Suspense> </Suspense>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper"> <main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background">
{children} {children}
</main> </main>

View File

@@ -932,6 +932,11 @@ export async function updateNote(id: string, data: {
} }
} }
if (data.labels !== undefined) {
const refreshed = await prisma.note.findUnique({ where: { id } })
if (refreshed) return parseNote(refreshed)
}
return parseNote(note) return parseNote(note)
} catch (error) { } catch (error) {
console.error('Error updating note:', error) console.error('Error updating note:', error)

View File

@@ -263,13 +263,21 @@ Content: ${noteContext.content || '(empty)'}
Focus ONLY on this note unless asked otherwise.` Focus ONLY on this note unless asked otherwise.`
} }
const systemPrompt = `${prompts.system}\n${copilotContext}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.` // Notebook scope directive — tells the AI to stay within the selected notebook
let notebookScopeDirective = ''
if (notebookId) {
const scopedNotebook = await prisma.notebook.findUnique({ where: { id: notebookId }, select: { name: true } }).catch(() => null)
const notebookName = scopedNotebook?.name || notebookId
notebookScopeDirective = `\n\n## NOTEBOOK SCOPE\nThe user has scoped this conversation to the notebook "${notebookName}". When using the note_search tool, ALWAYS pass notebookId="${notebookId}" to restrict results to this notebook. Only reference notes from this notebook unless the user explicitly asks otherwise.`
}
const systemPrompt = `${prompts.system}\n${copilotContext}${notebookScopeDirective}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.`
// 6. Execute stream // 6. Execute stream
const sysConfig = await getSystemConfig() const sysConfig = await getSystemConfig()
const chatTools = noteContext const chatTools = noteContext
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true }) ? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch }) : toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
const provider = getChatProvider(sysConfig) const provider = getChatProvider(sysConfig)
const result = await streamText({ const result = await streamText({

View File

@@ -25,10 +25,10 @@
--color-background-dark: #202020; --color-background-dark: #202020;
/* Design tokens from architectural-grid 10 */ /* Design tokens from architectural-grid 10 */
--color-ink: #1C1C1C; --color-ink: var(--ink);
--color-paper: #F2F0E9; --color-paper: var(--paper);
--color-muted-ink: rgba(28, 28, 28, 0.6); --color-muted-ink: var(--muted-ink);
--color-concrete: #8D8D8D; --color-concrete: var(--concrete);
--color-blueprint: #75B2D6; --color-blueprint: #75B2D6;
--color-ochre: #D4A373; --color-ochre: #D4A373;
--color-sage: #A3B18A; --color-sage: #A3B18A;
@@ -183,6 +183,7 @@ html:not(.dark) .memento-active-nav {
--ink: var(--foreground); --ink: var(--foreground);
--paper: var(--background); --paper: var(--background);
--muted-ink: var(--muted-foreground); --muted-ink: var(--muted-foreground);
--concrete: #8D8D8D;
--ai-accent: #ACB995; --ai-accent: #ACB995;
} }
@@ -424,6 +425,8 @@ html.dark {
--sidebar-accent-foreground: #1C1C1C; --sidebar-accent-foreground: #1C1C1C;
--sidebar-border: rgba(28, 28, 28, 0.1); --sidebar-border: rgba(28, 28, 28, 0.1);
--sidebar-ring: rgba(28, 28, 28, 0.35); --sidebar-ring: rgba(28, 28, 28, 0.35);
--thumb-lightness-1: 94%;
--thumb-lightness-2: 87%;
} }
[data-theme='light'].dark { [data-theme='light'].dark {
@@ -487,6 +490,9 @@ html.dark {
--sidebar-accent-foreground: #ffffff; --sidebar-accent-foreground: #ffffff;
--sidebar-border: #3d3d3d; --sidebar-border: #3d3d3d;
--sidebar-ring: #a8a29e; --sidebar-ring: #a8a29e;
--thumb-lightness-1: 15%;
--thumb-lightness-2: 10%;
--concrete: #A0A0A0;
} }
[data-theme='midnight'] { [data-theme='midnight'] {

View File

@@ -163,7 +163,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
return ( return (
<aside className={cn( <aside className={cn(
"fixed bottom-20 right-6 border border-border/40 bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300", "fixed bottom-20 right-6 border border-border/40 bg-memento-paper dark:bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]" isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
)}> )}>
{/* Header */} {/* Header */}
@@ -242,7 +242,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20"> <div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
</div> </div>
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm"> <div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<p className="text-sm text-foreground leading-relaxed"> <p className="text-sm text-foreground leading-relaxed">
{t('ai.welcomeMsg')} {t('ai.welcomeMsg')}
</p> </p>
@@ -269,7 +269,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm', 'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
msg.role === 'user' msg.role === 'user'
? 'bg-memento-blue text-white rounded-tr-sm' ? 'bg-memento-blue text-white rounded-tr-sm'
: 'bg-background border border-border/50 rounded-tl-sm text-foreground', : 'bg-memento-paper dark:bg-background border border-border/50 rounded-tl-sm text-foreground',
)}> )}>
{msg.role === 'assistant' {msg.role === 'assistant'
? <MarkdownContent content={text} /> ? <MarkdownContent content={text} />
@@ -284,7 +284,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20"> <div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
</div> </div>
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm"> <div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div> </div>
</div> </div>
@@ -350,7 +350,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</div> </div>
{/* Input Area & Tone Controls (Only in Chat tab) */} {/* Input Area & Tone Controls (Only in Chat tab) */}
<div className={cn("p-4 border-t border-border/40 bg-background shrink-0", activeTab !== 'chat' && "hidden")}> <div className={cn("p-4 border-t border-border/40 bg-memento-paper dark:bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
{/* Context Scope */} {/* Context Scope */}
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ml-1">Source du Contexte</span> <span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ml-1">Source du Contexte</span>
@@ -403,7 +403,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5", "py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
isSelected isSelected
? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm" ? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm"
: "border-border/60 bg-background text-muted-foreground hover:bg-muted hover:border-border" : "border-border/60 bg-memento-paper dark:bg-background text-muted-foreground hover:bg-muted hover:border-border"
)} )}
> >
<Icon className="h-3 w-3" /> <Icon className="h-3 w-3" />
@@ -415,7 +415,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
</div> </div>
{/* Text Input */} {/* Text Input */}
<div className="relative bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm"> <div className="relative bg-memento-paper dark:bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm">
<textarea <textarea
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]" className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
placeholder={t('ai.chatPlaceholder')} placeholder={t('ai.chatPlaceholder')}

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Button } from './ui/button'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,11 +9,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from './ui/dialog' } from './ui/dialog'
import { Checkbox } from './ui/checkbox' import { Sparkles, CheckCircle2, Loader2, Tag } from 'lucide-react'
import { Tag, Loader2, Sparkles, CheckCircle2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services' import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
import { cn } from '@/lib/utils'
interface AutoLabelSuggestionDialogProps { interface AutoLabelSuggestionDialogProps {
open: boolean open: boolean
@@ -35,12 +34,10 @@ export function AutoLabelSuggestionDialog({
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set()) const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set())
// Fetch suggestions when dialog opens with a notebook
useEffect(() => { useEffect(() => {
if (open && notebookId) { if (open && notebookId) {
fetchSuggestions() fetchSuggestions()
} else { } else {
// Reset state when closing
setSuggestions(null) setSuggestions(null)
setSelectedLabels(new Set()) setSelectedLabels(new Set())
} }
@@ -65,12 +62,13 @@ export function AutoLabelSuggestionDialog({
if (data.success && data.data) { if (data.success && data.data) {
setSuggestions(data.data) setSuggestions(data.data)
// Select all labels by default
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string)) const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
setSelectedLabels(allLabelNames) setSelectedLabels(allLabelNames)
} else { } else {
// No suggestions is not an error - just close the dialog
if (data.message) { if (data.message) {
toast.info(data.message)
} else {
toast.info(t('ai.autoLabels.noSuggestions') || 'Pas assez de notes pour générer des labels (minimum 15)')
} }
onOpenChange(false) onOpenChange(false)
} }
@@ -136,8 +134,10 @@ export function AutoLabelSuggestionDialog({
<DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle> <DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <div className="w-16 h-16 rounded-full border border-dashed border-memento-blue/20 flex items-center justify-center mb-4">
<p className="mt-4 text-sm text-muted-foreground"> <Loader2 className="h-6 w-6 animate-spin text-memento-blue" />
</div>
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{t('ai.autoLabels.analyzing')} {t('ai.autoLabels.analyzing')}
</p> </p>
</div> </div>
@@ -155,7 +155,7 @@ export function AutoLabelSuggestionDialog({
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" /> <Sparkles className="h-5 w-5 text-memento-blue" />
{t('ai.autoLabels.title')} {t('ai.autoLabels.title')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
@@ -166,60 +166,73 @@ export function AutoLabelSuggestionDialog({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-4"> <div className="space-y-2 py-4">
{suggestions.suggestedLabels.map((label) => ( {suggestions.suggestedLabels.map((label) => {
<div const isSelected = selectedLabels.has(label.name)
key={label.name} return (
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer" <div
onClick={() => toggleLabelSelection(label.name)} key={label.name}
> className={cn(
<Checkbox "flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-all",
checked={selectedLabels.has(label.name)} isSelected
onCheckedChange={() => toggleLabelSelection(label.name)} ? "bg-memento-blue/5 border-memento-blue/30 hover:bg-memento-blue/10"
aria-label={`Select label: ${label.name}`} : "border-border hover:bg-muted/50"
/> )}
<div className="flex-1 min-w-0"> onClick={() => toggleLabelSelection(label.name)}
<div className="flex items-center gap-2"> >
<Tag className="h-4 w-4 text-muted-foreground" /> <div className={cn(
<span className="font-medium">{label.name}</span> "w-5 h-5 rounded-full border-2 flex items-center justify-center mt-0.5 transition-all shrink-0",
isSelected
? "bg-memento-blue border-memento-blue"
: "border-border"
)}>
{isSelected && <CheckCircle2 className="h-3.5 w-3.5 text-white" />}
</div> </div>
<div className="flex items-center gap-3 mt-1"> <div className="flex-1 min-w-0">
<span className="text-xs text-muted-foreground"> <div className="flex items-center gap-2">
{t('ai.autoLabels.notesCount', { count: label.count })} <Tag className="h-3.5 w-3.5 text-memento-blue/60" />
</span> <span className="font-medium text-sm">{label.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary"> <Sparkles className="h-3 w-3 text-memento-blue/40" />
{Math.round(label.confidence * 100)}% {t('notebook.confidence')} </div>
</span> <div className="flex items-center gap-3 mt-1.5">
<span className="text-[10px] text-muted-foreground">
{t('ai.autoLabels.notesCount', { count: label.count })}
</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-memento-blue/10 text-memento-blue font-bold">
{Math.round(label.confidence * 100)}%
</span>
</div>
</div> </div>
</div> </div>
</div> )
))} })}
</div> </div>
<DialogFooter> <DialogFooter className="gap-2">
<Button <button
variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
disabled={creating} disabled={creating}
className="flex-1 py-3 border border-border rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-muted transition-all"
> >
{t('general.cancel')} {t('general.cancel')}
</Button> </button>
<Button <button
onClick={handleCreateLabels} onClick={handleCreateLabels}
disabled={selectedLabels.size === 0 || creating} disabled={selectedLabels.size === 0 || creating}
className="flex-1 py-3 bg-memento-blue text-white rounded-xl text-[10px] font-bold uppercase tracking-widest hover:opacity-90 transition-all shadow-lg shadow-memento-blue/20 disabled:opacity-50"
> >
{creating ? ( {creating ? (
<> <span className="flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
{t('ai.autoLabels.creating')} {t('ai.autoLabels.creating')}
</> </span>
) : ( ) : (
<> <span className="flex items-center justify-center gap-2">
<CheckCircle2 className="h-4 w-4 mr-2" /> <CheckCircle2 className="h-4 w-4" />
{t('ai.autoLabels.create')} {t('ai.autoLabels.create')}
</> </span>
)} )}
</Button> </button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -15,6 +15,7 @@ import {
GitMerge, PlusCircle, Eye, Code, Languages, GitMerge, PlusCircle, Eye, Code, Languages,
Presentation, PenTool, ExternalLink, ImagePlus, Presentation, PenTool, ExternalLink, ImagePlus,
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle, ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle,
Tag as TagIcon, RefreshCw,
} from 'lucide-react' } from 'lucide-react'
import { motion, AnimatePresence } from 'motion/react' import { motion, AnimatePresence } from 'motion/react'
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image' import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
@@ -47,6 +48,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { getNotebookIcon } from '@/lib/notebook-icon' import { getNotebookIcon } from '@/lib/notebook-icon'
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector' import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
import { scrapePageText } from '@/app/actions/scrape' import { scrapePageText } from '@/app/actions/scrape'
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -119,6 +121,10 @@ interface ContextualAIChatProps {
diagramInsertFormat?: 'markdown' | 'html' diagramInsertFormat?: 'markdown' | 'html'
/** Called to trigger AI title generation for the note */ /** Called to trigger AI title generation for the note */
onGenerateTitle?: () => void onGenerateTitle?: () => void
/** Notebook ID for label regeneration */
notebookId?: string
/** Notebook name for display */
notebookName?: string
} }
function CopyPreviewButton({ text }: { text: string }) { function CopyPreviewButton({ text }: { text: string }) {
@@ -170,6 +176,8 @@ export function ContextualAIChat({
className, className,
diagramInsertFormat = 'markdown', diagramInsertFormat = 'markdown',
onGenerateTitle, onGenerateTitle,
notebookId,
notebookName,
}: ContextualAIChatProps) { }: ContextualAIChatProps) {
const { t, language } = useLanguage() const { t, language } = useLanguage()
const webSearchAvailable = useWebSearchAvailable() const webSearchAvailable = useWebSearchAvailable()
@@ -209,6 +217,10 @@ export function ContextualAIChat({
// hoveredMsgId: which chat message shows inject actions // hoveredMsgId: which chat message shows inject actions
const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null) const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null)
// Label regeneration state
const [regenerateLabelsLoading, setRegenerateLabelsLoading] = useState(false)
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
@@ -513,6 +525,14 @@ export function ContextualAIChat({
} }
} }
const handleRegenerateLabels = () => {
if (!notebookId) {
mToast.error(t('ai.autoLabels.noNotebook') || 'Aucun carnet sélectionné')
return
}
setAutoLabelOpen(true)
}
return ( return (
<> <>
{expanded && ( {expanded && (
@@ -522,7 +542,7 @@ export function ContextualAIChat({
/> />
)} )}
<aside className={cn( <aside className={cn(
'border-l border-border bg-background flex flex-col flex-shrink-0 z-10 transition-all duration-300 shadow-2xl', 'border-l border-border bg-memento-paper dark:bg-background flex flex-col flex-shrink-0 z-10 transition-all duration-300 shadow-2xl',
expanded expanded
? 'fixed right-0 top-0 h-screen w-[640px] z-[200]' ? 'fixed right-0 top-0 h-screen w-[640px] z-[200]'
: 'h-full w-[360px]', : 'h-full w-[360px]',
@@ -585,7 +605,7 @@ export function ContextualAIChat({
<div className="flex-1 flex flex-col min-h-0 relative"> <div className="flex-1 flex flex-col min-h-0 relative">
{actionPreview && ( {actionPreview && (
<div className="absolute inset-0 z-20 flex flex-col bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300"> <div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0"> <div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">{actionPreview.label}</p> <p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">{actionPreview.label}</p>
<button onClick={handleDiscardPreview} className="text-foreground/40 hover:text-foreground"><X size={18} /></button> <button onClick={handleDiscardPreview} className="text-foreground/40 hover:text-foreground"><X size={18} /></button>
@@ -604,7 +624,7 @@ export function ContextualAIChat({
)} )}
{resourcePreview && ( {resourcePreview && (
<div className="absolute inset-0 z-20 flex flex-col bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300"> <div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
<div className="px-6 py-4 border-b border-border/40 flex items-center justify-between shrink-0"> <div className="px-6 py-4 border-b border-border/40 flex items-center justify-between shrink-0">
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue"> <p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
{resourcePreview.source === 'chat' ? 'Injecter depuis Discussion' : 'Aperçu IA'} {resourcePreview.source === 'chat' ? 'Injecter depuis Discussion' : 'Aperçu IA'}
@@ -770,6 +790,28 @@ export function ContextualAIChat({
exit={{ opacity: 0, x: -20 }} exit={{ opacity: 0, x: -20 }}
className="flex flex-col flex-1 overflow-y-auto p-6 space-y-10 custom-scrollbar" className="flex flex-col flex-1 overflow-y-auto p-6 space-y-10 custom-scrollbar"
> >
{notebookId && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40 whitespace-nowrap">{t('ai.organization') || 'Organisation'}</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<button
type="button"
onClick={handleRegenerateLabels}
className="w-full flex items-center gap-3 p-4 bg-card border border-border rounded-xl transition-all hover:border-memento-blue/30 cursor-pointer"
>
<div className="p-2 bg-card rounded-lg text-memento-blue shrink-0"><TagIcon size={18} /></div>
<div className="flex-1 text-left">
<h5 className="text-[10px] font-bold text-foreground">{t('ai.autoLabels.regenerate') || 'Labels IA'}</h5>
<p className="text-[8px] text-foreground/40 uppercase tracking-tight">{notebookName || ''}</p>
</div>
<RefreshCw size={14} className="text-memento-blue shrink-0" />
</button>
</div>
)}
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border/40" /> <div className="h-px flex-1 bg-border/40" />
@@ -1101,6 +1143,17 @@ export function ContextualAIChat({
</AnimatePresence> </AnimatePresence>
</div> </div>
</aside> </aside>
{autoLabelOpen && notebookId && (
<AutoLabelSuggestionDialog
open={autoLabelOpen}
onOpenChange={setAutoLabelOpen}
notebookId={notebookId}
onLabelsCreated={() => {
mToast.success(t('ai.autoLabels.created', { count: 0 }) || 'Labels créés')
}}
/>
)}
</> </>
) )
} }

View File

@@ -1,13 +1,12 @@
import React from 'react'; import React from 'react';
import { TagSuggestion } from '@/lib/ai/types'; import { TagSuggestion } from '@/lib/ai/types';
import { Loader2, Sparkles, X, CheckCircle, Plus } from 'lucide-react'; import { Sparkles, X, CheckCircle, Plus } from 'lucide-react';
import { cn, getHashColor } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { LABEL_COLORS } from '@/lib/types';
import { useLanguage } from '@/lib/i18n'; import { useLanguage } from '@/lib/i18n';
interface GhostTagsProps { interface GhostTagsProps {
suggestions: TagSuggestion[]; suggestions: TagSuggestion[];
addedTags: string[]; // Nouveauté : tags déjà présents sur la note addedTags: string[];
isAnalyzing: boolean; isAnalyzing: boolean;
onSelectTag: (tag: string) => void; onSelectTag: (tag: string) => void;
onDismissTag: (tag: string) => void; onDismissTag: (tag: string) => void;
@@ -17,85 +16,78 @@ interface GhostTagsProps {
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) { export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
const { t } = useLanguage() const { t } = useLanguage()
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
const visibleSuggestions = suggestions;
return ( return (
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}> <div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
{isAnalyzing && ( {isAnalyzing && (
<div className="flex items-center text-purple-500 animate-pulse" title={t('ai.analyzing')}> <div className="flex items-center gap-1.5 text-memento-blue animate-pulse">
<Sparkles className="w-4 h-4" /> <Sparkles className="w-3.5 h-3.5" />
<span className="text-[9px] font-bold uppercase tracking-wider">{t('ai.analyzing')}</span>
</div> </div>
)} )}
{/* Show message when no labels suggested */} {!isAnalyzing && suggestions.length === 0 && (
{!isAnalyzing && visibleSuggestions.length === 0 && ( <div className="text-[10px] text-muted-foreground italic">
<div className="text-xs text-gray-500 italic">
{t('ai.autoLabels.typeForSuggestions')} {t('ai.autoLabels.typeForSuggestions')}
</div> </div>
)} )}
{!isAnalyzing && visibleSuggestions.map((suggestion) => { {!isAnalyzing && suggestions.map((suggestion) => {
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase()); const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase())
const colorName = getHashColor(suggestion.tag); const isNewLabel = suggestion.isNewLabel
const colorClasses = LABEL_COLORS[colorName];
const isNewLabel = suggestion.isNewLabel;
if (isAdded) { if (isAdded) {
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus return (
return ( <div
<div key={suggestion.tag} className={cn("flex items-center px-3 py-1 text-xs font-medium border rounded-full opacity-50 cursor-default", colorClasses.bg, colorClasses.text, colorClasses.border)}> key={suggestion.tag}
<CheckCircle className="w-3 h-3 mr-1.5" /> className="flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border bg-memento-blue/5 border-memento-blue/20 text-memento-blue opacity-50 cursor-default"
{suggestion.tag} >
</div> <CheckCircle className="w-3 h-3 mr-1" />
) {suggestion.tag}
</div>
)
} }
return ( return (
<div <div
key={suggestion.tag} key={suggestion.tag}
className={cn( className="group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100 border-memento-blue/20 bg-memento-blue/5"
"group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100",
colorClasses.bg,
colorClasses.border
)}
> >
{/* Zone de validation (Clic principal) */} <button
<button type="button"
type="button" onClick={(e) => {
onClick={(e) => { e.preventDefault()
e.preventDefault(); e.stopPropagation()
e.stopPropagation(); onSelectTag(suggestion.tag)
onSelectTag(suggestion.tag); }}
}} className="flex items-center px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-memento-blue"
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)} title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')} >
> {isNewLabel ? (
{isNewLabel && <Plus className="w-3 h-3 mr-1" />} <Plus className="w-3 h-3 mr-1" />
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />} ) : (
{suggestion.tag} <Sparkles className="w-3 h-3 mr-1.5 opacity-60" />
{isNewLabel && <span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>} )}
</button> {suggestion.tag}
{isNewLabel && (
{/* Zone de refus (Croix) */} <span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>
<button )}
type="button" </button>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDismissTag(suggestion.tag);
}}
className={cn("pr-2 pl-1 hover:text-red-500 transition-colors", colorClasses.text)}
title={t('ai.ignoreSuggestion')}
>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
onDismissTag(suggestion.tag)
}}
className="pr-2 pl-1 text-memento-blue/60 hover:text-red-500 transition-colors"
title={t('ai.ignoreSuggestion')}
>
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
</div> </div>
); )
})} })}
</div> </div>
); )
} }

View File

@@ -111,7 +111,7 @@ export function HierarchicalNotebookSelector({
if (!searchQuery) setIsOpen(false) if (!searchQuery) setIsOpen(false)
}} }}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold dark:bg-blueprint/10' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`} ${isSelected ? 'bg-blueprint/10 text-blueprint font-bold dark:bg-blueprint/10' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
> >
<div className="w-4 flex items-center justify-center"> <div className="w-4 flex items-center justify-center">
{hasChildren ? ( {hasChildren ? (
@@ -124,7 +124,7 @@ export function HierarchicalNotebookSelector({
) : null} ) : null}
</div> </div>
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20 dark:bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}> <div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20 dark:bg-blueprint/20' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />} {isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
</div> </div>
@@ -157,7 +157,7 @@ export function HierarchicalNotebookSelector({
<div <div
ref={triggerRef} ref={triggerRef}
onClick={() => setIsOpen(prev => !prev)} onClick={() => setIsOpen(prev => !prev)}
className={`w-full bg-slate-50 dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`} className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
> >
<Folder size={size === 'sm' ? 14 : 16} className="text-blueprint/60 shrink-0" /> <Folder size={size === 'sm' ? 14 : 16} className="text-blueprint/60 shrink-0" />
<div className="flex-1 flex items-center gap-1 min-w-0"> <div className="flex-1 flex items-center gap-1 min-w-0">
@@ -192,7 +192,7 @@ export function HierarchicalNotebookSelector({
style={getDropdownStyle()} style={getDropdownStyle()}
className="bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col" className="bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col"
> >
<div className="p-3 border-b border-border/40 bg-slate-50/50 dark:bg-white/5"> <div className="p-3 border-b border-border/40 bg-card/50 dark:bg-white/5">
<div className="relative"> <div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" /> <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
<input <input
@@ -210,7 +210,7 @@ export function HierarchicalNotebookSelector({
{renderTree(null)} {renderTree(null)}
</div> </div>
<div className="p-2 border-t border-border/40 bg-slate-50/30 dark:bg-white/5 flex justify-between items-center px-4"> <div className="p-2 border-t border-border/40 bg-card/30 dark:bg-white/5 flex justify-between items-center px-4">
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest"> <span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">
{notebooks.length} notebooks {notebooks.length} notebooks
</span> </span>

View File

@@ -10,7 +10,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
import { MemoryEchoNotification } from '@/components/memory-echo-notification' import { MemoryEchoNotification } from '@/components/memory-echo-notification'
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast' import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight } from 'lucide-react' import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X } from 'lucide-react'
import { useNoteRefresh } from '@/context/NoteRefreshContext' import { useNoteRefresh } from '@/context/NoteRefreshContext'
import { useRefresh } from '@/lib/use-refresh' import { useRefresh } from '@/lib/use-refresh'
import { useReminderCheck } from '@/hooks/use-reminder-check' import { useReminderCheck } from '@/hooks/use-reminder-check'
@@ -88,11 +88,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
const [autoLabelOpen, setAutoLabelOpen] = useState(false) const [autoLabelOpen, setAutoLabelOpen] = useState(false)
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false) const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false) const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
const [tagSearchQuery, setTagSearchQuery] = useState('')
useEffect(() => { useEffect(() => {
if (shouldSuggestLabels && suggestNotebookId) { // Auto-trigger disabled — user opens manually from AI panel
setAutoLabelOpen(true) // if (shouldSuggestLabels && suggestNotebookId) {
} // setAutoLabelOpen(true)
// }
}, [shouldSuggestLabels, suggestNotebookId]) }, [shouldSuggestLabels, suggestNotebookId])
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid) // Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
@@ -278,7 +282,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
if (labelFilter.length > 0) { if (labelFilter.length > 0) {
allNotes = allNotes.filter((note: any) => allNotes = allNotes.filter((note: any) =>
note.labels?.some((label: string) => labelFilter.includes(label)) labelFilter.every((label: string) => note.labels?.includes(label))
) )
} }
@@ -341,6 +345,52 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
return trail return trail
}, [currentNotebook, notebooks]) }, [currentNotebook, notebooks])
const availableTags = useMemo(() => {
const tagsMap = new Map<string, { id: string; name: string; type?: string }>()
const carnetNotes = notes
carnetNotes.forEach(note => {
;(note.labels || []).forEach(labelName => {
if (!tagsMap.has(labelName)) {
const labelObj = labels.find((l: any) => l.name === labelName)
tagsMap.set(labelName, {
id: labelObj?.id || labelName,
name: labelName,
type: labelObj?.type,
})
}
})
})
return Array.from(tagsMap.values()).sort((a, b) => {
if (a.type === 'ai' && b.type !== 'ai') return -1
if (a.type !== 'ai' && b.type === 'ai') return 1
return a.name.localeCompare(b.name)
})
}, [notes, labels])
const visibleTags = useMemo(() => {
let filtered = availableTags
if (tagSearchQuery) {
filtered = availableTags.filter(t =>
t.name.toLowerCase().includes(tagSearchQuery.toLowerCase())
)
} else if (!isTagsExpanded) {
filtered = availableTags.slice(0, 10)
selectedTagIds.forEach(id => {
if (!filtered.find(t => t.id === id)) {
const tag = availableTags.find(t => t.id === id)
if (tag) filtered.push(tag)
}
})
}
return filtered
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds])
const toggleTag = useCallback((tagId: string) => {
setSelectedTagIds(prev =>
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
)
}, [])
useEffect(() => { useEffect(() => {
setControls({ setControls({
@@ -351,12 +401,22 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
// Apply sort order to notes // Apply sort order to notes
const sortedNotes = useMemo(() => { const sortedNotes = useMemo(() => {
const sorted = [...notes] let sorted = [...notes]
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || '')) if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
if (selectedTagIds.length > 0) {
const selectedNames = selectedTagIds
.map(id => availableTags.find(t => t.id === id)?.name)
.filter(Boolean) as string[]
sorted = sorted.filter(note =>
selectedNames.every(name => (note.labels || []).includes(name))
)
}
return sorted return sorted
}, [notes, sortOrder]) }, [notes, sortOrder, selectedTagIds, availableTags])
const sortedPinnedNotes = useMemo(() => { const sortedPinnedNotes = useMemo(() => {
return sortedNotes.filter(n => n.isPinned) return sortedNotes.filter(n => n.isPinned)
@@ -404,21 +464,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
fullPage fullPage
/> />
) : ( ) : (
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper flex flex-col"> <div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
<div <div
className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 backdrop-blur-md z-30" className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
> >
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
{currentNotebook && notebookPath.length > 0 && ( {currentNotebook && notebookPath.length > 0 && (
<div <div
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2" className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60"
style={{ color: 'var(--color-ink)', opacity: 1 }}
> >
{notebookPath.map((nb: any, i: number) => ( {notebookPath.map((nb: any, i: number) => (
<React.Fragment key={nb.id}> <React.Fragment key={nb.id}>
{i > 0 && <ChevronRight size={10} className="shrink-0" style={{ color: 'var(--color-concrete)' }} />} {i > 0 && <ChevronRight size={10} className="shrink-0 text-concrete" />}
<span style={{ color: i === notebookPath.length - 1 ? 'var(--color-ink)' : 'var(--color-concrete)' }}> <span className={i === notebookPath.length - 1 ? 'text-ink' : 'text-concrete'}>
{nb.name} {nb.name}
</span> </span>
</React.Fragment> </React.Fragment>
@@ -557,6 +616,84 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
</button> </button>
</div> </div>
</div> </div>
{availableTags.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
<TagIcon size={12} />
<span>{t('labels.filterByTags') || 'Filter by Tags'}</span>
{selectedTagIds.length > 0 && (
<span className="bg-memento-blue/10 text-memento-blue px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
{selectedTagIds.length} active
</span>
)}
</div>
{availableTags.length > 10 && (
<input
type="text"
placeholder={t('labels.searchTags') || 'Search tags...'}
className="bg-transparent border-b border-foreground/10 text-[10px] outline-none focus:border-memento-blue/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-muted-foreground/40"
value={tagSearchQuery}
onChange={e => setTagSearchQuery(e.target.value)}
/>
)}
</div>
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
<AnimatePresence mode="popLayout">
{visibleTags.map(tag => {
const isActive = selectedTagIds.includes(tag.id)
return (
<motion.button
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={cn(
'px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2',
isActive
? 'bg-foreground text-background border-foreground shadow-sm'
: 'bg-card/40 border-border text-muted-foreground hover:border-foreground/30 hover:bg-card/60',
)}
>
{tag.type === 'ai' && (
<Sparkles
size={10}
className={isActive ? 'text-memento-blue' : 'text-memento-blue/60'}
/>
)}
{tag.name}
{isActive && <X size={10} />}
</motion.button>
)
})}
</AnimatePresence>
{availableTags.length > 10 && !tagSearchQuery && (
<button
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60 hover:text-foreground transition-colors border border-dashed border-border rounded-full"
>
{isTagsExpanded
? (t('labels.showLess') || 'Show less')
: `+ ${availableTags.length - 10} more`}
</button>
)}
{selectedTagIds.length > 0 && (
<button
onClick={() => setSelectedTagIds([])}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-red-500 hover:underline ml-auto"
>
{t('labels.clearAll') || 'Clear all'}
</button>
)}
</div>
</div>
)}
</div> </div>
<div className="px-12 flex-1 pb-20"> <div className="px-12 flex-1 pb-20">

View File

@@ -1,14 +1,11 @@
'use client' 'use client'
import { Badge } from '@/components/ui/badge'
import { X, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { LABEL_COLORS } from '@/lib/types' import { X, Sparkles } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
interface LabelBadgeProps { interface LabelBadgeProps {
label: string label: string
type?: 'ai' | 'user' // Optional: if provided, applies AI vs User styling type?: 'ai' | 'user'
onRemove?: () => void onRemove?: () => void
variant?: 'default' | 'filter' | 'clickable' variant?: 'default' | 'filter' | 'clickable'
onClick?: () => void onClick?: () => void
@@ -25,46 +22,54 @@ export function LabelBadge({
isSelected = false, isSelected = false,
isDisabled = false, isDisabled = false,
}: LabelBadgeProps) { }: LabelBadgeProps) {
const { getLabelColor } = useNotebooks()
const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
// AI labels get special Blueprint styling with Sparkles icon
const isAI = type === 'ai' const isAI = type === 'ai'
return ( return (
<Badge <button
className={cn( type="button"
'text-xs border gap-1 transition-all',
isAI
? 'bg-blue-100/70 border-blue-200/50 text-sky-700 dark:bg-sky-900/30 dark:border-sky-700/50 dark:text-sky-300 hover:bg-blue-200/70'
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`,
variant === 'filter' && 'cursor-pointer hover:opacity-80',
variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50',
isSelected && 'ring-2 ring-primary'
)}
onClick={onClick} onClick={onClick}
disabled={isDisabled}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider transition-all',
variant === 'filter' && !isSelected && 'cursor-pointer',
variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50 cursor-not-allowed',
isSelected
? 'bg-foreground text-background border-foreground shadow-sm'
: isAI
? 'bg-[#75B2D6]/10 border-[#75B2D6]/25 text-[#75B2D6]'
: 'bg-[#8D8D8D]/10 border-[#8D8D8D]/25 text-[#8D8D8D]',
)}
> >
{isAI && <Sparkles className="h-3 w-3 text-[#75B2D6]" />} {isAI && (
<Sparkles size={8} className="text-[#75B2D6]/70" />
)}
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
{onRemove && ( {onRemove && (
<button <span
role="button"
tabIndex={0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onRemove() onRemove()
}} }}
className="hover:text-red-600" onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation()
onRemove()
}
}}
className="hover:text-red-500 transition-colors cursor-pointer"
> >
<X className="h-3 w-3" /> <X size={8} />
</button>
)}
{isAI && (
<span className="relative flex h-1.5 w-1.5 ml-1">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#75B2D6] opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#75B2D6]"></span>
</span> </span>
)} )}
</Badge> {isAI && !isSelected && (
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#75B2D6] opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#75B2D6]" />
</span>
)}
</button>
) )
} }

View File

@@ -11,7 +11,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from './ui/dialog' } from './ui/dialog'
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react' import { Settings, Plus, Palette, Trash2, Sparkles } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types' import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
@@ -19,7 +19,6 @@ import { useLanguage } from '@/lib/i18n'
import { useRefresh } from '@/lib/use-refresh' import { useRefresh } from '@/lib/use-refresh'
export interface LabelManagementDialogProps { export interface LabelManagementDialogProps {
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
open?: boolean open?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
} }
@@ -77,9 +76,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
className="max-w-md" className="max-w-md"
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'} dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
onInteractOutside={(event) => { onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const isSonnerElement = const isSonnerElement =
target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') || target.closest('[data-sonner-toaster]') ||
@@ -88,12 +85,10 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
target.closest('[data-description]') || target.closest('[data-description]') ||
target.closest('[data-title]') || target.closest('[data-title]') ||
target.closest('[data-button]'); target.closest('[data-button]');
if (isSonnerElement) { if (isSonnerElement) {
event.preventDefault(); event.preventDefault();
return; return;
} }
if (target.getAttribute('data-sonner-toaster') !== null) { if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -108,7 +103,6 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
{/* Add new label */}
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
placeholder={t('labels.newLabelPlaceholder')} placeholder={t('labels.newLabelPlaceholder')}
@@ -126,7 +120,6 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
</Button> </Button>
</div> </div>
{/* List labels */}
<div className="max-h-[60vh] overflow-y-auto space-y-2"> <div className="max-h-[60vh] overflow-y-auto space-y-2">
{loading ? ( {loading ? (
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p> <p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
@@ -136,14 +129,21 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
labels.map((label) => { labels.map((label) => {
const colorClasses = LABEL_COLORS[label.color] const colorClasses = LABEL_COLORS[label.color]
const isEditing = editingColorId === label.id const isEditing = editingColorId === label.id
const isAI = label.type === 'ai'
return ( return (
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group"> <div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
<div className="flex items-center gap-3 flex-1 relative"> <div className="flex items-center gap-3 flex-1 relative">
<Tag className={cn("h-4 w-4", colorClasses.text)} /> {isAI ? (
<Sparkles className={cn("h-4 w-4", "text-memento-blue")} />
) : (
<div className={cn("h-3 w-3 rounded-full", colorClasses.bg)} />
)}
<span className="font-medium text-sm">{label.name}</span> <span className="font-medium text-sm">{label.name}</span>
{isAI && (
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-memento-blue/10 text-memento-blue font-bold uppercase">IA</span>
)}
{/* Color Picker Popover */}
{isEditing && ( {isEditing && (
<div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48"> <div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
<div className="grid grid-cols-5 gap-2"> <div className="grid grid-cols-5 gap-2">
@@ -155,7 +155,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
className={cn( className={cn(
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110', 'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
classes.bg, classes.bg,
label.color === color ? 'border-gray-900 dark:border-gray-100 ring-2 ring-offset-1' : 'border-transparent' label.color === color ? 'border-foreground dark:border-foreground ring-2 ring-offset-1' : 'border-transparent'
)} )}
onClick={() => handleChangeColor(label.id, color)} onClick={() => handleChangeColor(label.id, color)}
title={color} title={color}

View File

@@ -12,8 +12,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from './ui/dialog' } from './ui/dialog'
import { Badge } from './ui/badge' import { Tag, X, Plus, Palette, AlertCircle, Sparkles } from 'lucide-react'
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
import { LABEL_COLORS, LabelColorName } from '@/lib/types' import { LABEL_COLORS, LabelColorName } from '@/lib/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
@@ -34,18 +33,16 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
const [editingColor, setEditingColor] = useState<string | null>(null) const [editingColor, setEditingColor] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Sync selected labels with existingLabels prop
useEffect(() => { useEffect(() => {
setSelectedLabels(existingLabels) setSelectedLabels(existingLabels)
}, [existingLabels]) }, [existingLabels])
const handleAddLabel = async () => { const handleAddLabel = async () => {
const trimmed = newLabel.trim() const trimmed = newLabel.trim()
setErrorMessage(null) // Clear previous error setErrorMessage(null)
if (trimmed && !selectedLabels.includes(trimmed)) { if (trimmed && !selectedLabels.includes(trimmed)) {
try { try {
// Get existing label color or use random
const existingLabel = labels.find(l => l.name === trimmed) const existingLabel = labels.find(l => l.name === trimmed)
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)] const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
@@ -113,9 +110,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
<DialogContent <DialogContent
className="max-w-md" className="max-w-md"
onInteractOutside={(event) => { onInteractOutside={(event) => {
// Prevent dialog from closing when interacting with Sonner toasts
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const isSonnerElement = const isSonnerElement =
target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toast]') ||
target.closest('[data-sonner-toaster]') || target.closest('[data-sonner-toaster]') ||
@@ -124,12 +119,10 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
target.closest('[data-description]') || target.closest('[data-description]') ||
target.closest('[data-title]') || target.closest('[data-title]') ||
target.closest('[data-button]'); target.closest('[data-button]');
if (isSonnerElement) { if (isSonnerElement) {
event.preventDefault(); event.preventDefault();
return; return;
} }
if (target.getAttribute('data-sonner-toaster') !== null) { if (target.getAttribute('data-sonner-toaster') !== null) {
event.preventDefault(); event.preventDefault();
return; return;
@@ -144,7 +137,6 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
{/* Error message */}
{errorMessage && ( {errorMessage && (
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900"> <div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" /> <AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" />
@@ -152,14 +144,13 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
</div> </div>
)} )}
{/* Add new label */}
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
placeholder={t('labels.newLabelPlaceholder')} placeholder={t('labels.newLabelPlaceholder')}
value={newLabel} value={newLabel}
onChange={(e) => { onChange={(e) => {
setNewLabel(e.target.value) setNewLabel(e.target.value)
setErrorMessage(null) // Clear error when typing setErrorMessage(null)
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -173,20 +164,20 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
</Button> </Button>
</div> </div>
{/* Selected labels */}
{selectedLabels.length > 0 && ( {selectedLabels.length > 0 && (
<div> <div>
<h4 className="text-sm font-medium mb-2">{t('labels.selectedLabels')}</h4> <h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">{t('labels.selectedLabels')}</h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedLabels.map((label) => { {selectedLabels.map((label) => {
const labelObj = labels.find(l => l.name === label) const labelObj = labels.find(l => l.name === label)
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
const isEditing = editingColor === label const isEditing = editingColor === label
const isAI = labelObj?.type === 'ai'
return ( return (
<div key={label} className="relative"> <div key={label} className="relative">
{isEditing && labelObj ? ( {isEditing && labelObj ? (
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2"> <div className="absolute z-10 top-8 left-0 bg-popover border rounded-lg shadow-lg p-2">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => { {(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
const classes = LABEL_COLORS[color] const classes = LABEL_COLORS[color]
@@ -196,7 +187,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
className={cn( className={cn(
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110', 'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
classes.bg, classes.bg,
labelObj.color === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600' labelObj.color === color ? 'border-foreground dark:border-foreground' : 'border-border'
)} )}
onClick={() => handleChangeColor(label, color)} onClick={() => handleChangeColor(label, color)}
title={color} title={color}
@@ -206,27 +197,30 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
</div> </div>
</div> </div>
) : null} ) : null}
<Badge <span
className={cn( className={cn(
'text-xs border cursor-pointer pr-1 flex items-center gap-1', 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider cursor-pointer',
colorClasses.bg, isAI
colorClasses.text, ? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
colorClasses.border : `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
)} )}
onClick={() => setEditingColor(isEditing ? null : label)} onClick={() => setEditingColor(isEditing ? null : label)}
> >
{isAI && <Sparkles className="h-3 w-3" />}
<Palette className="h-3 w-3" /> <Palette className="h-3 w-3" />
{label} {label}
<button <span
role="button"
tabIndex={0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
handleRemoveLabel(label) handleRemoveLabel(label)
}} }}
className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5" className="ml-1 hover:text-red-500 transition-colors cursor-pointer"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </span>
</Badge> </span>
</div> </div>
) )
})} })}
@@ -234,30 +228,30 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
</div> </div>
)} )}
{/* Available labels from context */}
{!loading && labels.length > 0 && ( {!loading && labels.length > 0 && (
<div> <div>
<h4 className="text-sm font-medium mb-2">{t('labels.allLabels')}</h4> <h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">{t('labels.allLabels')}</h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{labels {labels
.filter(label => !selectedLabels.includes(label.name)) .filter(label => !selectedLabels.includes(label.name))
.map((label) => { .map((label) => {
const colorClasses = LABEL_COLORS[label.color] const colorClasses = LABEL_COLORS[label.color]
const isAI = label.type === 'ai'
return ( return (
<Badge <button
key={label.id} key={label.id}
className={cn( className={cn(
'text-xs border cursor-pointer', 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider cursor-pointer transition-all hover:opacity-80',
colorClasses.bg, isAI
colorClasses.text, ? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
colorClasses.border, : `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
'hover:opacity-80'
)} )}
onClick={() => handleSelectExisting(label.name)} onClick={() => handleSelectExisting(label.name)}
> >
{isAI && <Sparkles className="h-3 w-3" />}
{label.name} {label.name}
</Badge> </button>
) )
})} })}
</div> </div>

View File

@@ -26,30 +26,8 @@ export function NoteContentArea() {
return data.url return data.url
} }
if (state.noteType === 'richtext') { // Markdown preview mode
if (fullPage) { if (state.isMarkdown && state.showMarkdownPreview) {
return (
<div className="fullpage-editor">
<RichTextEditor
content={state.content}
onChange={(v: string) => actions.setContent(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
/>
</div>
)
}
return (
<RichTextEditor
content={state.content}
onChange={actions.setContent}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
/>
)
}
if (state.noteType === 'markdown' && state.showMarkdownPreview) {
return ( return (
<div <div
className={cn( className={cn(
@@ -68,7 +46,8 @@ export function NoteContentArea() {
) )
} }
if (state.noteType === 'markdown' || state.noteType === 'text') { // Markdown edit mode
if (state.isMarkdown) {
if (fullPage) { if (fullPage) {
return ( return (
<div className="relative"> <div className="relative">
@@ -93,12 +72,11 @@ export function NoteContentArea() {
) )
} }
// Dialog mode
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Textarea <Textarea
dir="auto" dir="auto"
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')} placeholder={t('notes.takeNoteMarkdown') || t('notes.takeNote')}
value={state.content} value={state.content}
onChange={(e) => actions.setContent(e.target.value)} onChange={(e) => actions.setContent(e.target.value)}
disabled={readOnly} disabled={readOnly}
@@ -118,62 +96,35 @@ export function NoteContentArea() {
) )
} }
// Checklist mode // Richtext mode (default)
if (fullPage) { if (fullPage) {
return ( return (
<div className="space-y-2"> <div className="fullpage-editor">
{state.checkItems.map((item) => ( <RichTextEditor
<div key={item.id} className="flex items-start gap-2 group"> content={state.content}
<Checkbox onChange={(v: string) => actions.setContent(v)}
checked={item.checked} className="min-h-[280px]"
onCheckedChange={() => actions.handleCheckItem(item.id)} onImageUpload={uploadImageFile}
className="mt-2" />
/>
<Input
value={item.text}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
</div> </div>
) )
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{state.checkItems.map((item) => ( <RichTextEditor
<div key={item.id} className="flex items-start gap-2 group"> content={state.content}
<Checkbox onChange={actions.setContent}
checked={item.checked} className="min-h-[200px]"
onCheckedChange={() => actions.handleCheckItem(item.id)} onImageUpload={uploadImageFile}
className="mt-2" />
/> <GhostTags
<Input suggestions={state.filteredSuggestions}
value={item.text} addedTags={state.labels}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)} isAnalyzing={state.isAnalyzingSuggestions}
placeholder={t('notes.listItem')} onSelectTag={actions.handleSelectGhostTag}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent" onDismissTag={actions.handleDismissGhostTag}
/> />
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
</div> </div>
) )
} }

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react' import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, NoteSize } from '@/lib/types' import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes' import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape' import { fetchLinkMetadata } from '@/app/actions/scrape'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
@@ -48,7 +48,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
}, [session?.user?.id]) }, [session?.user?.id])
// Core content state
const [title, setTitle] = useState(note.title || '') const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content) const [content, setContent] = useState(note.content)
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || []) const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
@@ -60,16 +59,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const [size, setSize] = useState<NoteSize>(note.size || 'small') const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([]) const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [noteType, setNoteType] = useState<NoteType>(note.type) const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown')
const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown') const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
// Refs
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const prevNoteRef = useRef(note) const prevNoteRef = useRef(note)
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
useEffect(() => { useEffect(() => {
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) { if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
setTitle(note.title || '') setTitle(note.title || '')
@@ -80,40 +76,54 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setLinks(note.links || []) setLinks(note.links || [])
setColor(note.color) setColor(note.color)
setSize(note.size || 'small') setSize(note.size || 'small')
setNoteType(note.type) setIsMarkdown(note.type === 'markdown')
setShowMarkdownPreview(note.type === 'markdown') setShowMarkdownPreview(note.type === 'markdown')
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null) setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
} }
prevNoteRef.current = note prevNoteRef.current = note
}, [note]) }, [note])
// Update context notebookId when note changes
useEffect(() => { useEffect(() => {
setContextNotebookId(note.notebookId || null) setContextNotebookId(note.notebookId || null)
}, [note.notebookId, setContextNotebookId]) }, [note.notebookId, setContextNotebookId])
// Auto-tagging hook const [dismissedTags, setDismissedTags] = useState<string[]>([])
const dismissedTagsLoadedRef = useRef(false)
useEffect(() => {
dismissedTagsLoadedRef.current = false
try {
const stored = localStorage.getItem(`dismissed-tags-${note.id}`)
if (stored) {
setDismissedTags(JSON.parse(stored))
dismissedTagsLoadedRef.current = true
} else {
setDismissedTags([])
}
} catch (_) {
setDismissedTags([])
}
}, [note.id])
const autoTaggingEnabled = autoLabelingEnabled && dismissedTags.length < 3
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({ const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
content: noteType !== 'checklist' ? content : '', content: content,
notebookId: note.notebookId, notebookId: note.notebookId,
enabled: noteType !== 'checklist' && autoLabelingEnabled enabled: autoTaggingEnabled
}) })
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false) const [showReminderDialog, setShowReminderDialog] = useState(false)
const [currentReminder, setCurrentReminder] = useState<Date | null>( const [currentReminder, setCurrentReminder] = useState<Date | null>(
note.reminder ? new Date(note.reminder as unknown as string) : null note.reminder ? new Date(note.reminder as unknown as string) : null
) )
// Link state
const [showLinkDialog, setShowLinkDialog] = useState(false) const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('') const [linkUrl, setLinkUrl] = useState('')
// Title suggestions state
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([]) const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false) const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
// Reformulation state
const [isReformulating, setIsReformulating] = useState(false) const [isReformulating, setIsReformulating] = useState(false)
const [reformulationModal, setReformulationModal] = useState<{ const [reformulationModal, setReformulationModal] = useState<{
originalText: string originalText: string
@@ -121,38 +131,28 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
option: string option: string
} | null>(null) } | null>(null)
// AI processing state
const [isProcessingAI, setIsProcessingAI] = useState(false) const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false) const [aiOpen, setAiOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false) const [infoOpen, setInfoOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false) const [isDirty, setIsDirty] = useState(false)
// fullPage — auto title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false) const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({ const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content, content,
enabled: fullPage && !title && !dismissedTitleSuggestions, enabled: fullPage && !title && !dismissedTitleSuggestions,
}) })
// Wire autoTitleSuggestions into state so NoteTitleBlock can display them
useEffect(() => { useEffect(() => {
if (autoTitleSuggestions.length > 0) { if (autoTitleSuggestions.length > 0) {
setTitleSuggestions(autoTitleSuggestions) setTitleSuggestions(autoTitleSuggestions)
} }
}, [autoTitleSuggestions]) }, [autoTitleSuggestions])
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null) const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
// Memory Echo Connections state
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([]) const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([]) const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
// Tags dismissed by the user for this session
const [dismissedTags, setDismissedTags] = useState<string[]>([])
// Filter suggestions to exclude dismissed ones
// and those already present on the note
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase()) const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(s => { const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false if (!s || !s.tag) return false
@@ -185,10 +185,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
} }
// Paste handler: upload clipboard images
useEffect(() => { useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => { const handlePaste = async (e: ClipboardEvent) => {
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return; if (!isMarkdown && (e.target as HTMLElement)?.closest('.notion-editor')) return;
const items = e.clipboardData?.items const items = e.clipboardData?.items
if (!items) return if (!items) return
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
@@ -207,9 +206,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
document.addEventListener('paste', handlePaste, { capture: true }) document.addEventListener('paste', handlePaste, { capture: true })
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any) return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [t, noteType]) }, [t, isMarkdown])
// Auto-grow textarea as content grows
useEffect(() => { useEffect(() => {
const el = textareaRef.current const el = textareaRef.current
if (!el) return if (!el) return
@@ -217,10 +215,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
el.style.height = Math.max(el.scrollHeight, 280) + 'px' el.style.height = Math.max(el.scrollHeight, 280) + 'px'
}, [content]) }, [content])
// Also auto-grow when switching FROM preview TO edit mode
useEffect(() => { useEffect(() => {
if (showMarkdownPreview) return // we're in preview, textarea not mounted if (showMarkdownPreview) return
// Defer one frame so the textarea is in the DOM
const raf = requestAnimationFrame(() => { const raf = requestAnimationFrame(() => {
const el = textareaRef.current const el = textareaRef.current
if (!el) return if (!el) return
@@ -234,7 +230,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleRemoveImage = (index: number) => { const handleRemoveImage = (index: number) => {
const removedUrl = images[index] const removedUrl = images[index]
setImages(images.filter((_, i) => i !== index)) setImages(images.filter((_, i) => i !== index))
// Track removed images for cleanup on save
if (removedUrl) { if (removedUrl) {
setRemovedImageUrls(prev => [...prev, removedUrl]) setRemovedImageUrls(prev => [...prev, removedUrl])
} }
@@ -267,9 +262,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
const allImages = useMemo(() => { const allImages = useMemo(() => {
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : []; const extracted = !isMarkdown ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted])); return Array.from(new Set([...images, ...extracted]));
}, [images, content, noteType]); }, [images, content, isMarkdown]);
const handleGenerateTitles = async () => { const handleGenerateTitles = async () => {
const fullContentForAI = [ const fullContentForAI = [
@@ -301,7 +296,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const data = await response.json() const data = await response.json()
setTitleSuggestions(data.suggestions || []) setTitleSuggestions(data.suggestions || [])
// Auto-apply first title for dialog mode (fullPage shows suggestions UI instead)
if (!fullPage && data.suggestions?.[0]?.title) { if (!fullPage && data.suggestions?.[0]?.title) {
setTitle(data.suggestions[0].title) setTitle(data.suggestions[0].title)
setDismissedTitleSuggestions(true) setDismissedTitleSuggestions(true)
@@ -485,7 +479,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (!response.ok) throw new Error(data.error || t('notes.transformFailed')) if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
setContent(data.transformedText) setContent(data.transformedText)
setNoteType('markdown') setIsMarkdown(true)
setShowMarkdownPreview(false) setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess')) toast.success(t('ai.transformSuccess'))
@@ -500,13 +494,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleApplyRefactor = () => { const handleApplyRefactor = () => {
if (!reformulationModal) return if (!reformulationModal) return
const selectedText = window.getSelection()?.toString() setContent(reformulationModal.reformulatedText)
if (selectedText) {
setContent(reformulationModal.reformulatedText)
} else {
setContent(reformulationModal.reformulatedText)
}
setReformulationModal(null) setReformulationModal(null)
toast.success(t('ai.reformulationApplied')) toast.success(t('ai.reformulationApplied'))
} }
@@ -536,35 +524,27 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
const handleSave = async () => { const handleSave = async () => {
console.log('[SAVE] handleSave called, note.id:', note.id)
setIsSaving(true) setIsSaving(true)
try { try {
console.log('[SAVE] Calling updateNote...')
const result = await updateNote(note.id, { const result = await updateNote(note.id, {
title: title.trim() || null, title: title.trim() || null,
content: noteType !== 'checklist' ? content : '', content,
checkItems: noteType === 'checklist' ? checkItems : null, checkItems: null,
labels, labels,
images, images,
links, links,
color, color,
reminder: currentReminder, reminder: currentReminder,
isMarkdown: noteType === 'markdown', isMarkdown,
type: noteType, type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size, size,
}) })
console.log('[SAVE] updateNote succeeded, result title:', result?.title, 'result content len:', result?.content?.length)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
// Keep local note ref in sync with saved data so useEffect detects changes correctly
prevNoteRef.current = { ...prevNoteRef.current, ...result } prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
if (removedImageUrls.length > 0) { if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {}) cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
} }
await refreshLabels() await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result) onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) }) queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) }) queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh() triggerRefresh()
@@ -607,6 +587,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (!tagExists) { if (!tagExists) {
setLabels(prev => [...prev, tag]) setLabels(prev => [...prev, tag])
setIsDirty(true)
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase()) const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) { if (!globalExists) {
@@ -621,11 +602,16 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
const handleDismissGhostTag = (tag: string) => { const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => [...prev, tag]) setDismissedTags(prev => {
const next = [...prev, tag]
try { localStorage.setItem(`dismissed-tags-${note.id}`, JSON.stringify(next)) } catch (_) {}
return next
})
} }
const handleRemoveLabel = (label: string) => { const handleRemoveLabel = (label: string) => {
setLabels(labels.filter(l => l !== label)) setLabels(labels.filter(l => l !== label))
setIsDirty(true)
} }
const handleMakeCopy = async () => { const handleMakeCopy = async () => {
@@ -638,54 +624,42 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
labels: labels, labels: labels,
images: images, images: images,
links: links, links: links,
isMarkdown: noteType === 'markdown', isMarkdown,
type: noteType, type: isMarkdown ? 'markdown' : 'richtext',
size: size, size: size,
}) })
toast.success(t('notes.copySuccess')) toast.success(t('notes.copySuccess'))
// Invalidate notes list cache for current notebook
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) }) queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh() triggerRefresh()
// Note: onClose is handled by the composition component
} catch (error) { } catch (error) {
console.error('Failed to copy note:', error) console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed')) toast.error(t('notes.copyFailed'))
} }
} }
// Save in place (fullPage) — without closing
const handleSaveInPlace = async () => { const handleSaveInPlace = async () => {
console.log('[SAVE] handleSaveInPlace called, note.id:', note.id, 'content length:', content.length, 'title:', title.substring(0, 50))
setIsSaving(true) setIsSaving(true)
try { try {
console.log('[SAVE] Calling updateNote with note.id:', note.id, '| content len:', content.length, '| title:', title.substring(0, 30))
const updatePayload = { const updatePayload = {
title: title.trim() || null, title: title.trim() || null,
content: noteType !== 'checklist' ? content : '', content,
checkItems: noteType === 'checklist' ? checkItems : null, checkItems: null,
labels, labels,
images, images,
links, links,
color, color,
reminder: currentReminder, reminder: currentReminder,
isMarkdown: noteType === 'markdown', isMarkdown,
type: noteType, type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size, size,
} }
console.log('[SAVE] payload.content:', JSON.stringify(updatePayload.content)?.substring(0, 100))
const result = await updateNote(note.id, updatePayload) const result = await updateNote(note.id, updatePayload)
console.log('[SAVE] updateNote succeeded, result.id:', result?.id, '| result.content len:', result?.content?.length, '| result.title:', result?.title)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
// Sync local note reference with saved data so prop/state stay aligned after save
prevNoteRef.current = { ...prevNoteRef.current, ...result } prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
if (removedImageUrls.length > 0) { if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {}) cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
} }
await refreshLabels() await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result) onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) }) queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) }) queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh() triggerRefresh()
@@ -699,7 +673,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
} }
} }
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
useEffect(() => { useEffect(() => {
if (!fullPage) return if (!fullPage) return
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@@ -712,7 +685,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return () => document.removeEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler)
}, [fullPage, isSaving]) }, [fullPage, isSaving])
// Build state object
const state: NoteEditorState = useMemo(() => ({ const state: NoteEditorState = useMemo(() => ({
title, title,
content, content,
@@ -723,7 +695,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
newLabel, newLabel,
color: color as NoteColor, color: color as NoteColor,
size, size,
noteType,
showMarkdownPreview, showMarkdownPreview,
removedImageUrls, removedImageUrls,
isSaving, isSaving,
@@ -750,7 +721,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
allImages, allImages,
colorClasses, colorClasses,
}), [ }), [
title, content, checkItems, labels, images, links, newLabel, color, size, noteType, title, content, checkItems, labels, images, links, newLabel, color, size,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen, showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating, isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder, reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
@@ -758,10 +729,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
]) ])
// Build actions object — NOT memoized to avoid stale closures.
// handleSave / handleSaveInPlace close over content, title, labels, etc.
// which change on every keystroke. Memoizing with [] would freeze those
// values at the first render, causing the wrong content to be saved.
const actions: NoteEditorActions = { const actions: NoteEditorActions = {
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) }, setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions, setDismissedTitleSuggestions,
@@ -782,8 +749,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setLinks, setLinks,
handleAddLink, handleAddLink,
handleRemoveLink, handleRemoveLink,
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) }, setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
setIsMarkdown: (m) => { setIsMarkdown(m); setIsDirty(true) },
setColor: (c) => { setColor(c); setIsDirty(true) }, setColor: (c) => { setColor(c); setIsDirty(true) },
setSize: (s) => { setSize(s); setIsDirty(true) }, setSize: (s) => { setSize(s); setIsDirty(true) },
setShowReminderDialog, setShowReminderDialog,
@@ -815,7 +782,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setPreviousContentForCopilot, setPreviousContentForCopilot,
} }
const value: NoteEditorContextValue = useMemo(() => ({ const value: NoteEditorContextValue = useMemo(() => ({
note, note,
readOnly, readOnly,
@@ -841,4 +807,4 @@ export function useNoteEditorContext() {
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider') throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
} }
return context return context
} }

View File

@@ -207,6 +207,8 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
} : undefined} } : undefined}
lastActionApplied={state.previousContentForCopilot !== null} lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks} notebooks={notebooks}
notebookId={note.notebookId ?? undefined}
notebookName={notebooks.find(nb => nb.id === note.notebookId)?.name ?? undefined}
/> />
)} )}
</DialogContent> </DialogContent>

View File

@@ -14,23 +14,30 @@ import { format } from 'date-fns'
import { ChevronRight } from 'lucide-react' import { ChevronRight } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Note } from '@/lib/types' import { Note } from '@/lib/types'
import { GhostTags } from '@/components/ghost-tags'
import { LabelBadge } from '@/components/label-badge'
interface NoteEditorFullPageProps { interface NoteEditorFullPageProps {
onClose: () => void onClose: () => void
} }
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) { export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext() const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
const getLabelType = (name: string): 'ai' | 'user' => {
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
return (found as any)?.type === 'ai' ? 'ai' : 'user'
}
return ( return (
<> <>
{/* ── outer container ── */} {/* ── outer container ── */}
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500"> <div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
{/* ── main scrollable column ── */} {/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-zinc-950"> <div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
{/* TOOLBAR */} {/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} /> <NoteEditorToolbar mode="fullPage" onClose={onClose} />
@@ -51,6 +58,28 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{/* Title */} {/* Title */}
<NoteTitleBlock /> <NoteTitleBlock />
{(state.labels.length > 0 || (state.filteredSuggestions.length > 0)) && (
<div className="flex flex-wrap gap-2 pt-2">
{state.labels.map((label) => (
<LabelBadge
key={label}
label={label}
type={getLabelType(label)}
onRemove={() => actions.handleRemoveLabel(label)}
/>
))}
{!readOnly && (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
</div>
)}
</div> </div>
{/* Hero image — show first note image if present */} {/* Hero image — show first note image if present */}
@@ -83,12 +112,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
onApplyToNote={(nc: string) => { onApplyToNote={(nc: string) => {
actions.setPreviousContentForCopilot(state.content) actions.setPreviousContentForCopilot(state.content)
actions.setContent(nc) actions.setContent(nc)
if (state.noteType === 'markdown') actions.setShowMarkdownPreview(true) if (state.isMarkdown) actions.setShowMarkdownPreview(true)
}} }}
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined} onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={state.previousContentForCopilot !== null} lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks} notebooks={notebooks}
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'} notebookId={note.notebookId ?? undefined}
notebookName={notebookName ?? undefined}
diagramInsertFormat={state.isMarkdown ? 'markdown' : 'html'}
onGenerateTitle={async () => { onGenerateTitle={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim() const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length const wordCount = plain.split(/\s+/).filter(Boolean).length

View File

@@ -7,7 +7,6 @@ import { LabelBadge } from '@/components/label-badge'
import { GhostTags } from '@/components/ghost-tags' import { GhostTags } from '@/components/ghost-tags'
import { EditorImages } from '@/components/editor-images' import { EditorImages } from '@/components/editor-images'
import { TitleSuggestions } from '@/components/title-suggestions' import { TitleSuggestions } from '@/components/title-suggestions'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -44,32 +43,28 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
// Snapshot for undo — stored in a ref so the toast callback isn't a stale closure const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
const undoSnapshotRef = useRef<{ content: string; noteType: string } | null>(null)
const handleConvertToRichtext = async () => { const handleConvertToRichtext = async () => {
if (isConverting || !state.content.trim()) return if (isConverting || !state.content.trim()) return
setIsConverting(true) setIsConverting(true)
// Capture snapshot BEFORE converting const snapshot = { content: state.content, isMarkdown: state.isMarkdown }
const snapshot = { content: state.content, noteType: state.noteType }
undoSnapshotRef.current = snapshot undoSnapshotRef.current = snapshot
try { try {
let html: string let html: string
if (state.noteType === 'markdown') { if (state.isMarkdown) {
// Proper markdown → HTML via marked (no AI needed)
const { marked } = await import('marked') const { marked } = await import('marked')
html = await marked(state.content, { async: false }) as string html = await marked(state.content, { async: false }) as string
} else { } else {
// Plain text → wrap paragraphs in <p> tags
html = state.content html = state.content
.split(/\n{2,}/) .split(/\n{2,}/)
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`) .map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
.join('') .join('')
} }
actions.setContent(html) actions.setContent(html)
actions.setNoteType('richtext') actions.setIsMarkdown(false)
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', { toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
duration: 8000, duration: 8000,
@@ -79,7 +74,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const snap = undoSnapshotRef.current const snap = undoSnapshotRef.current
if (!snap) return if (!snap) return
actions.setContent(snap.content) actions.setContent(snap.content)
actions.setNoteType(snap.noteType as any) if (snap.isMarkdown) actions.setIsMarkdown(true)
undoSnapshotRef.current = null undoSnapshotRef.current = null
toast.info(t('ai.undoApplied') || 'Conversion undone') toast.info(t('ai.undoApplied') || 'Conversion undone')
}, },
@@ -94,8 +89,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
if (mode === 'fullPage') { if (mode === 'fullPage') {
return ( return (
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10"> <div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
{/* Left: back */}
<button <button
onClick={onClose} onClick={onClose}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity" className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
@@ -104,9 +98,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
<span className="text-sm font-medium">Back to collection</span> <span className="text-sm font-medium">Back to collection</span>
</button> </button>
{/* Right: status + type + AI + Info */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Save status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none"> <span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{state.isSaving {state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></> ? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></>
@@ -115,15 +107,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>} : <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
</span> </span>
{/* Note type */} {state.isMarkdown && !readOnly && (
<NoteTypeSelector
value={state.noteType}
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
compact
/>
{/* Preview toggle — icon only */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button <button
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'} title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'} aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
@@ -139,8 +123,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button> </button>
)} )}
{/* Convert to Rich Text — icon only */} {state.isMarkdown && !readOnly && (
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
<button <button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'} title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'} aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
@@ -156,7 +139,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button> </button>
)} )}
{/* AI — icon only */}
<button <button
title="AI Assistant" title="AI Assistant"
aria-label="Ouvrir l'assistant IA" aria-label="Ouvrir l'assistant IA"
@@ -171,7 +153,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
<Sparkles size={16} /> <Sparkles size={16} />
</button> </button>
{/* Save — icon only */}
{!readOnly && ( {!readOnly && (
<button <button
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'} title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
@@ -189,7 +170,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button> </button>
)} )}
{/* Share button */}
{!readOnly && ( {!readOnly && (
<button <button
title="Partager la note" title="Partager la note"
@@ -201,8 +181,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button> </button>
)} )}
{/* Three-dot options menu */}
{!readOnly && ( {!readOnly && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -229,7 +207,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenu> </DropdownMenu>
)} )}
{/* Share Dialog portal */}
{shareOpen && ( {shareOpen && (
<NoteShareDialog <NoteShareDialog
noteId={note.id} noteId={note.id}
@@ -238,7 +215,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
/> />
)} )}
{/* Info panel toggle — rightmost, icon only */}
<button <button
aria-label="Informations du document" aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }} onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
@@ -256,32 +232,26 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
) )
} }
// Dialog toolbar
return ( return (
<div className="flex items-center justify-between pt-3 border-t border-border/30"> <div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{!readOnly && ( {!readOnly && (
<> <>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')} <Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}> onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
</Button> </Button>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" <Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}> onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
</Button> </Button>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" <Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}> onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" /> <LinkIcon className="h-4 w-4" />
</Button> </Button>
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} /> {state.isMarkdown && (
{state.noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" <Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)} onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}> title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
@@ -289,17 +259,13 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</Button> </Button>
)} )}
{/* AI Copilot */} <Button variant="ghost" size="sm"
{state.noteType !== 'checklist' && ( className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
<Button variant="ghost" size="sm" onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')} <Sparkles className="h-3.5 w-3.5" />
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note"> <span className="hidden sm:inline">IA Note</span>
<Sparkles className="h-3.5 w-3.5" /> </Button>
<span className="hidden sm:inline">IA Note</span>
</Button>
)}
{/* Size Selector */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
@@ -319,7 +285,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Color Picker */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
@@ -338,7 +303,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Label Manager */}
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} /> <LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
</> </>
)} )}
@@ -394,4 +358,4 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</div> </div>
</div> </div>
) )
} }

View File

@@ -6,7 +6,12 @@ import { GhostTags } from '../ghost-tags'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function NoteMetadataSection() { export function NoteMetadataSection() {
const { state, actions, readOnly } = useNoteEditorContext() const { state, actions, readOnly, globalLabels } = useNoteEditorContext()
const getLabelType = (name: string): 'ai' | 'user' => {
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
return (found as any)?.type === 'ai' ? 'ai' : 'user'
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -17,6 +22,7 @@ export function NoteMetadataSection() {
<LabelBadge <LabelBadge
key={label} key={label}
label={label} label={label}
type={getLabelType(label)}
onRemove={() => actions.handleRemoveLabel(label)} onRemove={() => actions.handleRemoveLabel(label)}
/> />
))} ))}
@@ -24,7 +30,7 @@ export function NoteMetadataSection() {
)} )}
{/* Ghost Tags - only show in dialog mode */} {/* Ghost Tags - only show in dialog mode */}
{!readOnly && state.noteType !== 'richtext' && ( {!readOnly && !state.isMarkdown && (
<GhostTags <GhostTags
suggestions={state.filteredSuggestions} suggestions={state.filteredSuggestions}
addedTags={state.labels} addedTags={state.labels}

View File

@@ -1,10 +1,8 @@
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType, LinkMetadata, NoteSize } from '@/lib/types' import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions' import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types' import type { TagSuggestion } from '@/lib/ai/types'
// State interface - all local state from NoteEditor
export interface NoteEditorState { export interface NoteEditorState {
// Core content state
title: string title: string
content: string content: string
checkItems: CheckItem[] checkItems: CheckItem[]
@@ -14,15 +12,12 @@ export interface NoteEditorState {
newLabel: string newLabel: string
color: NoteColor color: NoteColor
size: NoteSize size: NoteSize
noteType: NoteType
// UI state
showMarkdownPreview: boolean showMarkdownPreview: boolean
removedImageUrls: string[] removedImageUrls: string[]
isSaving: boolean isSaving: boolean
isDirty: boolean isDirty: boolean
// AI state
isProcessingAI: boolean isProcessingAI: boolean
aiOpen: boolean aiOpen: boolean
infoOpen: boolean infoOpen: boolean
@@ -37,107 +32,84 @@ export interface NoteEditorState {
} | null } | null
previousContentForCopilot: string | null previousContentForCopilot: string | null
// Reminder state
showReminderDialog: boolean showReminderDialog: boolean
currentReminder: Date | null currentReminder: Date | null
// Link dialog state
showLinkDialog: boolean showLinkDialog: boolean
linkUrl: string linkUrl: string
// Memory Echo Connections
comparisonNotes: Array<Partial<Note>> comparisonNotes: Array<Partial<Note>>
fusionNotes: Array<Partial<Note>> fusionNotes: Array<Partial<Note>>
// Ghost tags
dismissedTags: string[] dismissedTags: string[]
// Tag suggestions (from auto-tagging)
filteredSuggestions: TagSuggestion[] filteredSuggestions: TagSuggestion[]
isAnalyzingSuggestions: boolean isAnalyzingSuggestions: boolean
// Context-derived values
isMarkdown: boolean isMarkdown: boolean
allImages: string[] allImages: string[]
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS] colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
} }
// Actions interface - all handlers from NoteEditor
export interface NoteEditorActions { export interface NoteEditorActions {
// Title actions
setTitle: (title: string) => void setTitle: (title: string) => void
setDismissedTitleSuggestions: (dismissed: boolean) => void setDismissedTitleSuggestions: (dismissed: boolean) => void
// Content actions
setContent: (content: string) => void setContent: (content: string) => void
// CheckItems actions
setCheckItems: (items: CheckItem[]) => void setCheckItems: (items: CheckItem[]) => void
handleCheckItem: (id: string) => void handleCheckItem: (id: string) => void
handleUpdateCheckItem: (id: string, text: string) => void handleUpdateCheckItem: (id: string, text: string) => void
handleAddCheckItem: () => void handleAddCheckItem: () => void
handleRemoveCheckItem: (id: string) => void handleRemoveCheckItem: (id: string) => void
// Labels actions
setLabels: (labels: string[]) => void setLabels: (labels: string[]) => void
handleSelectGhostTag: (tag: string) => void handleSelectGhostTag: (tag: string) => void
handleDismissGhostTag: (tag: string) => void handleDismissGhostTag: (tag: string) => void
handleRemoveLabel: (label: string) => void handleRemoveLabel: (label: string) => void
// Images actions
setImages: (images: string[]) => void setImages: (images: string[]) => void
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
handleRemoveImage: (index: number) => void handleRemoveImage: (index: number) => void
uploadImageFile: (file: File) => Promise<string> uploadImageFile: (file: File) => Promise<string>
// Links actions
setLinks: (links: LinkMetadata[]) => void setLinks: (links: LinkMetadata[]) => void
handleAddLink: () => Promise<void> handleAddLink: () => Promise<void>
handleRemoveLink: (index: number) => void handleRemoveLink: (index: number) => void
// Note properties
setNoteType: (type: NoteType) => void
setShowMarkdownPreview: (show: boolean) => void setShowMarkdownPreview: (show: boolean) => void
setIsMarkdown: (markdown: boolean) => void
setColor: (color: NoteColor) => void setColor: (color: NoteColor) => void
setSize: (size: NoteSize) => void setSize: (size: NoteSize) => void
// Reminder actions
setShowReminderDialog: (show: boolean) => void setShowReminderDialog: (show: boolean) => void
setCurrentReminder: (date: Date | null) => void setCurrentReminder: (date: Date | null) => void
handleReminderSave: (date: Date) => Promise<void> handleReminderSave: (date: Date) => Promise<void>
handleRemoveReminder: () => Promise<void> handleRemoveReminder: () => Promise<void>
// Link dialog
setShowLinkDialog: (show: boolean) => void setShowLinkDialog: (show: boolean) => void
setLinkUrl: (url: string) => void setLinkUrl: (url: string) => void
// Title suggestions
handleGenerateTitles: () => Promise<void> handleGenerateTitles: () => Promise<void>
handleSelectTitle: (title: string) => void handleSelectTitle: (title: string) => void
// Reformulation
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void> handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
handleApplyRefactor: () => void handleApplyRefactor: () => void
// AI Direct handlers
handleClarifyDirect: () => Promise<void> handleClarifyDirect: () => Promise<void>
handleShortenDirect: () => Promise<void> handleShortenDirect: () => Promise<void>
handleImproveDirect: () => Promise<void> handleImproveDirect: () => Promise<void>
handleTransformMarkdown: () => Promise<void> handleTransformMarkdown: () => Promise<void>
// Save actions
handleSave: () => Promise<void> handleSave: () => Promise<void>
handleSaveInPlace: () => Promise<void> handleSaveInPlace: () => Promise<void>
handleMakeCopy: () => Promise<void> handleMakeCopy: () => Promise<void>
// Memory Echo
setComparisonNotes: (notes: Array<Partial<Note>>) => void setComparisonNotes: (notes: Array<Partial<Note>>) => void
setFusionNotes: (notes: Array<Partial<Note>>) => void setFusionNotes: (notes: Array<Partial<Note>>) => void
// Modal states
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
// State setters
setIsDirty: (dirty: boolean) => void setIsDirty: (dirty: boolean) => void
setAiOpen: (open: boolean) => void setAiOpen: (open: boolean) => void
setInfoOpen: (open: boolean) => void setInfoOpen: (open: boolean) => void
@@ -147,28 +119,14 @@ export interface NoteEditorActions {
setPreviousContentForCopilot: (content: string | null) => void setPreviousContentForCopilot: (content: string | null) => void
} }
// Context value - combines state + actions + note reference
export interface NoteEditorContextValue { export interface NoteEditorContextValue {
// The current note (external source of truth)
note: Note note: Note
// Read-only flag
readOnly: boolean readOnly: boolean
// FullPage flag
fullPage: boolean fullPage: boolean
// All state
state: NoteEditorState state: NoteEditorState
// All actions
actions: NoteEditorActions actions: NoteEditorActions
// Computed values from contexts
notebooks: Array<{ id: string; name: string }> notebooks: Array<{ id: string; name: string }>
globalLabels: Array<{ name: string }> globalLabels: Array<{ name: string }>
// Refs
fileInputRef: React.RefObject<HTMLInputElement | null> fileInputRef: React.RefObject<HTMLInputElement | null>
textareaRef: React.RefObject<HTMLTextAreaElement | null> textareaRef: React.RefObject<HTMLTextAreaElement | null>
} }

View File

@@ -7,6 +7,7 @@ import { useLanguage } from '@/lib/i18n'
import { useRefresh } from '@/lib/use-refresh' import { useRefresh } from '@/lib/use-refresh'
import { motion, AnimatePresence } from 'motion/react' import { motion, AnimatePresence } from 'motion/react'
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen } from 'lucide-react' import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen } from 'lucide-react'
import { useLabelsQuery } from '@/lib/query-hooks'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { getAISettings } from '@/app/actions/ai-settings' import { getAISettings } from '@/app/actions/ai-settings'
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration' import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
@@ -275,7 +276,7 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
return ( return (
<div <div
className="h-full w-full flex items-center justify-center relative overflow-hidden" className="h-full w-full flex items-center justify-center relative overflow-hidden"
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% 94%) 0%, hsl(${hue} 18% 87%) 100%)` }} style={{ background: `linear-gradient(145deg, hsl(${hue} 25% var(--thumb-lightness-1, 94%)) 0%, hsl(${hue} 18% var(--thumb-lightness-2, 87%)) 100%)` }}
> >
{/* Decorative concentric circles */} {/* Decorative concentric circles */}
<svg <svg
@@ -314,6 +315,18 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
) )
} }
function NoteTag({ labelName, allLabels }: { labelName: string; allLabels: any[] }) {
const labelDef = allLabels?.find(l => l.name === labelName)
const isAI = labelDef?.type === 'ai'
return (
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-paper dark:bg-white/5 text-[9px] font-bold uppercase tracking-[0.15em] text-muted-foreground border border-border/40">
{isAI && <Sparkles size={8} className="text-blueprint" />}
{labelName}
</div>
)
}
export function NotesEditorialView({ export function NotesEditorialView({
notes, notes,
onOpen, onOpen,
@@ -322,6 +335,7 @@ export function NotesEditorialView({
}: NotesEditorialViewProps) { }: NotesEditorialViewProps) {
const { t } = useLanguage() const { t } = useLanguage()
const { data: session } = useSession() const { data: session } = useSession()
const { data: allLabels } = useLabelsQuery()
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false) const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
useEffect(() => { useEffect(() => {
@@ -374,6 +388,13 @@ export function NotesEditorialView({
<div className="flex flex-col md:flex-row gap-8 items-start"> <div className="flex flex-col md:flex-row gap-8 items-start">
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} /> <EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
<div className="space-y-3 flex-1"> <div className="space-y-3 flex-1">
{note.labels && note.labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{note.labels.slice(0, 2).map((labelName) => (
<NoteTag key={labelName} labelName={labelName} allLabels={allLabels || []} />
))}
</div>
)}
{excerpt ? ( {excerpt ? (
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4"> <p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
{excerpt} {excerpt}

View File

@@ -42,8 +42,8 @@ export function SettingsNav({ className }: SettingsNavProps) {
className={cn( className={cn(
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2', 'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
isActive(section.href) isActive(section.href)
? 'border-[#D4A373] text-[#1C1C1C]' ? 'border-[#D4A373] text-ink'
: 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]' : 'border-transparent text-muted-ink hover:text-ink'
)} )}
> >
{section.icon} {section.icon}

View File

@@ -23,9 +23,12 @@ import {
Bell, Bell,
Pencil, Pencil,
Clock, Clock,
Moon,
Sun,
} from 'lucide-react' } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { applyDocumentTheme } from '@/lib/apply-document-theme'
import { getAllNotes, getTrashCount } from '@/app/actions/notes' import { getAllNotes, getTrashCount } from '@/app/actions/notes'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useNotebooks } from '@/context/notebooks-context' import { useNotebooks } from '@/context/notebooks-context'
@@ -151,7 +154,7 @@ function SidebarCarnetItem({
onDoubleClick={(e) => { e.stopPropagation(); onRename() }} onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
className={cn( className={cn(
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative', 'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative',
isActive ? 'bg-white shadow-sm border border-border/40' : 'hover:bg-white/40' isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
)} )}
> >
{isActive && ( {isActive && (
@@ -163,9 +166,9 @@ function SidebarCarnetItem({
)} )}
<div className={cn( <div className={cn(
'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all', 'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all',
isActive isActive
? 'bg-blueprint text-white border-blueprint' ? 'bg-blueprint text-white border-blueprint'
: 'bg-white/60 text-ink border-border' : 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
)}> )}>
{carnet.initial} {carnet.initial}
</div> </div>
@@ -260,6 +263,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
const [createParentId, setCreateParentId] = useState<string | null>(null) const [createParentId, setCreateParentId] = useState<string | null>(null)
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null) const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
const [renameValue, setRenameValue] = useState('') const [renameValue, setRenameValue] = useState('')
const [isDark, setIsDark] = useState(false)
useEffect(() => {
setIsDark(document.documentElement.classList.contains('dark'))
}, [])
const toggleTheme = useCallback(() => {
const next = !isDark
setIsDark(next)
const theme = next ? 'dark' : 'light'
localStorage.setItem('theme-preference', theme)
applyDocumentTheme(theme)
}, [isDark])
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null) const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
@@ -581,7 +597,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
{user?.image ? ( {user?.image ? (
<Avatar className="size-10 ring-1 ring-border/60"> <Avatar className="size-10 ring-1 ring-border/60">
<AvatarImage src={user.image} alt="" /> <AvatarImage src={user.image} alt="" />
<AvatarFallback className="bg-secondary text-sm font-semibold text-[#1C1C1C]/60">{initial}</AvatarFallback> <AvatarFallback className="bg-secondary text-sm font-semibold text-muted-ink">{initial}</AvatarFallback>
</Avatar> </Avatar>
) : ( ) : (
<span>{initial}</span> <span>{initial}</span>
@@ -621,10 +637,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Notification bell + Notebooks / Agents toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={toggleTheme}
className="p-2 text-muted-foreground hover:text-foreground transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
>
{isDark ? <Sun size={14} /> : <Moon size={14} />}
</button>
<NotificationPanel /> <NotificationPanel />
<div className="flex bg-white/50 p-1 rounded-full border border-border transition-all"> <div className="flex bg-white/50 dark:bg-white/5 p-1 rounded-full border border-border dark:border-white/10 transition-all">
<button <button
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }} onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink')} className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink')}
@@ -721,7 +743,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0', 'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
isInboxActive isInboxActive
? 'bg-ink text-paper border-ink' ? 'bg-ink text-paper border-ink'
: 'bg-white/60 text-ink border-border' : 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
)}> )}>
<Inbox size={14} /> <Inbox size={14} />
</div> </div>
@@ -784,14 +806,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
href={item.href} href={item.href}
className={cn( className={cn(
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group', 'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-white/40 hover:text-foreground' isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
)} )}
> >
<div className={cn( <div className={cn(
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0', 'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
isActive isActive
? 'bg-foreground text-background border-foreground' ? 'bg-foreground text-background border-foreground'
: 'bg-white/60 border-border group-hover:border-foreground/20' : 'bg-paper border-border group-hover:border-foreground/20'
)}> )}>
<item.icon size={16} /> <item.icon size={16} />
</div> </div>
@@ -814,7 +836,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl', 'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
searchParams.get('shared') === '1' && pathname === '/' searchParams.get('shared') === '1' && pathname === '/'
? 'bg-blueprint/5 text-blueprint' ? 'bg-blueprint/5 text-blueprint'
: 'text-muted-ink hover:text-ink hover:bg-black/5' : 'text-muted-ink hover:text-ink hover:bg-foreground/5'
)} )}
> >
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} /> <Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
@@ -823,7 +845,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
<Link <Link
href="/archive" href="/archive"
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl" className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-foreground/5 transition-all font-medium group rounded-xl"
> >
<Archive size={14} className="text-muted-ink group-hover:text-ink" /> <Archive size={14} className="text-muted-ink group-hover:text-ink" />
<span>{t('sidebar.archive')}</span> <span>{t('sidebar.archive')}</span>
@@ -853,7 +875,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl', 'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
pathname.startsWith('/settings') pathname.startsWith('/settings')
? 'bg-ink text-paper shadow-sm' ? 'bg-ink text-paper shadow-sm'
: 'text-muted-ink hover:text-ink hover:bg-black/5' : 'text-muted-ink hover:text-ink hover:bg-foreground/5'
)} )}
> >
<Settings size={14} className={pathname.startsWith('/settings') ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} /> <Settings size={14} className={pathname.startsWith('/settings') ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />

View File

@@ -20,7 +20,9 @@ toolRegistry.register({
limit: z.number().optional().describe('Max results to return (default 5)').default(5), limit: z.number().optional().describe('Max results to return (default 5)').default(5),
notebookId: z.string().optional().describe('Optional notebook ID to restrict search to a specific notebook'), notebookId: z.string().optional().describe('Optional notebook ID to restrict search to a specific notebook'),
}), }),
execute: async ({ query, limit = 5, notebookId }) => { execute: async ({ query, limit = 5, notebookId: explicitNotebookId }) => {
// If no notebookId passed explicitly, fall back to the chat scope from context
const notebookId = explicitNotebookId || ctx.notebookId
try { try {
// Keyword fallback search using Prisma // Keyword fallback search using Prisma
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2) const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)

View File

@@ -16,6 +16,7 @@ export interface Notebook {
parentId: string | null; parentId: string | null;
trashedAt?: Date | null; trashedAt?: Date | null;
userId: string; userId: string;
isPrivate?: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
notes?: Note[]; notes?: Note[];