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
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m24s
This commit is contained in:
@@ -31,12 +31,12 @@ export default async function MainLayout({
|
||||
return (
|
||||
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||
{/* 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" />}>
|
||||
<Sidebar user={session?.user} />
|
||||
</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}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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)
|
||||
} catch (error) {
|
||||
console.error('Error updating note:', error)
|
||||
|
||||
@@ -263,13 +263,21 @@ Content: ${noteContext.content || '(empty)'}
|
||||
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
|
||||
const sysConfig = await getSystemConfig()
|
||||
const chatTools = noteContext
|
||||
? 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 result = await streamText({
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
--color-background-dark: #202020;
|
||||
|
||||
/* Design tokens from architectural-grid 10 */
|
||||
--color-ink: #1C1C1C;
|
||||
--color-paper: #F2F0E9;
|
||||
--color-muted-ink: rgba(28, 28, 28, 0.6);
|
||||
--color-concrete: #8D8D8D;
|
||||
--color-ink: var(--ink);
|
||||
--color-paper: var(--paper);
|
||||
--color-muted-ink: var(--muted-ink);
|
||||
--color-concrete: var(--concrete);
|
||||
--color-blueprint: #75B2D6;
|
||||
--color-ochre: #D4A373;
|
||||
--color-sage: #A3B18A;
|
||||
@@ -183,6 +183,7 @@ html:not(.dark) .memento-active-nav {
|
||||
--ink: var(--foreground);
|
||||
--paper: var(--background);
|
||||
--muted-ink: var(--muted-foreground);
|
||||
--concrete: #8D8D8D;
|
||||
--ai-accent: #ACB995;
|
||||
}
|
||||
|
||||
@@ -424,6 +425,8 @@ html.dark {
|
||||
--sidebar-accent-foreground: #1C1C1C;
|
||||
--sidebar-border: rgba(28, 28, 28, 0.1);
|
||||
--sidebar-ring: rgba(28, 28, 28, 0.35);
|
||||
--thumb-lightness-1: 94%;
|
||||
--thumb-lightness-2: 87%;
|
||||
}
|
||||
|
||||
[data-theme='light'].dark {
|
||||
@@ -487,6 +490,9 @@ html.dark {
|
||||
--sidebar-accent-foreground: #ffffff;
|
||||
--sidebar-border: #3d3d3d;
|
||||
--sidebar-ring: #a8a29e;
|
||||
--thumb-lightness-1: 15%;
|
||||
--thumb-lightness-2: 10%;
|
||||
--concrete: #A0A0A0;
|
||||
}
|
||||
|
||||
[data-theme='midnight'] {
|
||||
|
||||
@@ -163,7 +163,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
|
||||
return (
|
||||
<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]"
|
||||
)}>
|
||||
{/* 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">
|
||||
<Bot className="h-4 w-4" />
|
||||
</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">
|
||||
{t('ai.welcomeMsg')}
|
||||
</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',
|
||||
msg.role === 'user'
|
||||
? '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'
|
||||
? <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">
|
||||
<Bot className="h-4 w-4" />
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +350,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
@@ -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",
|
||||
isSelected
|
||||
? "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" />
|
||||
@@ -415,7 +415,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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')}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from './ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,11 +9,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { Tag, Loader2, Sparkles, CheckCircle2 } from 'lucide-react'
|
||||
import { Sparkles, CheckCircle2, Loader2, Tag } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AutoLabelSuggestionDialogProps {
|
||||
open: boolean
|
||||
@@ -35,12 +34,10 @@ export function AutoLabelSuggestionDialog({
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set())
|
||||
|
||||
// Fetch suggestions when dialog opens with a notebook
|
||||
useEffect(() => {
|
||||
if (open && notebookId) {
|
||||
fetchSuggestions()
|
||||
} else {
|
||||
// Reset state when closing
|
||||
setSuggestions(null)
|
||||
setSelectedLabels(new Set())
|
||||
}
|
||||
@@ -65,12 +62,13 @@ export function AutoLabelSuggestionDialog({
|
||||
|
||||
if (data.success && data.data) {
|
||||
setSuggestions(data.data)
|
||||
// Select all labels by default
|
||||
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
|
||||
setSelectedLabels(allLabelNames)
|
||||
} else {
|
||||
// No suggestions is not an error - just close the dialog
|
||||
if (data.message) {
|
||||
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)
|
||||
}
|
||||
@@ -136,8 +134,10 @@ export function AutoLabelSuggestionDialog({
|
||||
<DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
<div className="w-16 h-16 rounded-full border border-dashed border-memento-blue/20 flex items-center justify-center mb-4">
|
||||
<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')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,7 +155,7 @@ export function AutoLabelSuggestionDialog({
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<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')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -166,60 +166,73 @@ export function AutoLabelSuggestionDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
{suggestions.suggestedLabels.map((label) => (
|
||||
<div
|
||||
key={label.name}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => toggleLabelSelection(label.name)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedLabels.has(label.name)}
|
||||
onCheckedChange={() => toggleLabelSelection(label.name)}
|
||||
aria-label={`Select label: ${label.name}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{label.name}</span>
|
||||
<div className="space-y-2 py-4">
|
||||
{suggestions.suggestedLabels.map((label) => {
|
||||
const isSelected = selectedLabels.has(label.name)
|
||||
return (
|
||||
<div
|
||||
key={label.name}
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "bg-memento-blue/5 border-memento-blue/30 hover:bg-memento-blue/10"
|
||||
: "border-border hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => toggleLabelSelection(label.name)}
|
||||
>
|
||||
<div className={cn(
|
||||
"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 className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('ai.autoLabels.notesCount', { count: label.count })}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
||||
{Math.round(label.confidence * 100)}% {t('notebook.confidence')}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-3.5 w-3.5 text-memento-blue/60" />
|
||||
<span className="font-medium text-sm">{label.name}</span>
|
||||
<Sparkles className="h-3 w-3 text-memento-blue/40" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
<DialogFooter className="gap-2">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
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')}
|
||||
</Button>
|
||||
<Button
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateLabels}
|
||||
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 ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('ai.autoLabels.creating')}
|
||||
</>
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t('ai.autoLabels.create')}
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
GitMerge, PlusCircle, Eye, Code, Languages,
|
||||
Presentation, PenTool, ExternalLink, ImagePlus,
|
||||
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle,
|
||||
Tag as TagIcon, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
||||
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
|
||||
import { scrapePageText } from '@/app/actions/scrape'
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -119,6 +121,10 @@ interface ContextualAIChatProps {
|
||||
diagramInsertFormat?: 'markdown' | 'html'
|
||||
/** Called to trigger AI title generation for the note */
|
||||
onGenerateTitle?: () => void
|
||||
/** Notebook ID for label regeneration */
|
||||
notebookId?: string
|
||||
/** Notebook name for display */
|
||||
notebookName?: string
|
||||
}
|
||||
|
||||
function CopyPreviewButton({ text }: { text: string }) {
|
||||
@@ -170,6 +176,8 @@ export function ContextualAIChat({
|
||||
className,
|
||||
diagramInsertFormat = 'markdown',
|
||||
onGenerateTitle,
|
||||
notebookId,
|
||||
notebookName,
|
||||
}: ContextualAIChatProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const webSearchAvailable = useWebSearchAvailable()
|
||||
@@ -209,6 +217,10 @@ export function ContextualAIChat({
|
||||
// hoveredMsgId: which chat message shows inject actions
|
||||
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 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 (
|
||||
<>
|
||||
{expanded && (
|
||||
@@ -522,7 +542,7 @@ export function ContextualAIChat({
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
? 'fixed right-0 top-0 h-screen w-[640px] z-[200]'
|
||||
: 'h-full w-[360px]',
|
||||
@@ -585,7 +605,7 @@ export function ContextualAIChat({
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{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">
|
||||
<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>
|
||||
@@ -604,7 +624,7 @@ export function ContextualAIChat({
|
||||
)}
|
||||
|
||||
{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">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
|
||||
{resourcePreview.source === 'chat' ? 'Injecter depuis Discussion' : 'Aperçu IA'}
|
||||
@@ -770,6 +790,28 @@ export function ContextualAIChat({
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
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="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/40" />
|
||||
@@ -1101,6 +1143,17 @@ export function ContextualAIChat({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{autoLabelOpen && notebookId && (
|
||||
<AutoLabelSuggestionDialog
|
||||
open={autoLabelOpen}
|
||||
onOpenChange={setAutoLabelOpen}
|
||||
notebookId={notebookId}
|
||||
onLabelsCreated={() => {
|
||||
mToast.success(t('ai.autoLabels.created', { count: 0 }) || 'Labels créés')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
import { TagSuggestion } from '@/lib/ai/types';
|
||||
import { Loader2, Sparkles, X, CheckCircle, Plus } from 'lucide-react';
|
||||
import { cn, getHashColor } from '@/lib/utils';
|
||||
import { LABEL_COLORS } from '@/lib/types';
|
||||
import { Sparkles, X, CheckCircle, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLanguage } from '@/lib/i18n';
|
||||
|
||||
interface GhostTagsProps {
|
||||
suggestions: TagSuggestion[];
|
||||
addedTags: string[]; // Nouveauté : tags déjà présents sur la note
|
||||
addedTags: string[];
|
||||
isAnalyzing: boolean;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onDismissTag: (tag: string) => void;
|
||||
@@ -17,85 +16,78 @@ interface GhostTagsProps {
|
||||
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
|
||||
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
|
||||
const visibleSuggestions = suggestions;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
|
||||
|
||||
{isAnalyzing && (
|
||||
<div className="flex items-center text-purple-500 animate-pulse" title={t('ai.analyzing')}>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<div className="flex items-center gap-1.5 text-memento-blue animate-pulse">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider">{t('ai.analyzing')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show message when no labels suggested */}
|
||||
{!isAnalyzing && visibleSuggestions.length === 0 && (
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
{!isAnalyzing && suggestions.length === 0 && (
|
||||
<div className="text-[10px] text-muted-foreground italic">
|
||||
{t('ai.autoLabels.typeForSuggestions')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAnalyzing && visibleSuggestions.map((suggestion) => {
|
||||
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase());
|
||||
const colorName = getHashColor(suggestion.tag);
|
||||
const colorClasses = LABEL_COLORS[colorName];
|
||||
const isNewLabel = suggestion.isNewLabel;
|
||||
{!isAnalyzing && suggestions.map((suggestion) => {
|
||||
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase())
|
||||
const isNewLabel = suggestion.isNewLabel
|
||||
|
||||
if (isAdded) {
|
||||
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus
|
||||
return (
|
||||
<div key={suggestion.tag} className={cn("flex items-center px-3 py-1 text-xs font-medium border rounded-full opacity-50 cursor-default", colorClasses.bg, colorClasses.text, colorClasses.border)}>
|
||||
<CheckCircle className="w-3 h-3 mr-1.5" />
|
||||
{suggestion.tag}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={suggestion.tag}
|
||||
className="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"
|
||||
>
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
{suggestion.tag}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={suggestion.tag}
|
||||
className={cn(
|
||||
"group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100",
|
||||
colorClasses.bg,
|
||||
colorClasses.border
|
||||
)}
|
||||
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"
|
||||
>
|
||||
{/* Zone de validation (Clic principal) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectTag(suggestion.tag);
|
||||
}}
|
||||
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
|
||||
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
|
||||
>
|
||||
{isNewLabel && <Plus className="w-3 h-3 mr-1" />}
|
||||
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}
|
||||
{suggestion.tag}
|
||||
{isNewLabel && <span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Zone de refus (Croix) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDismissTag(suggestion.tag);
|
||||
}}
|
||||
className={cn("pr-2 pl-1 hover:text-red-500 transition-colors", colorClasses.text)}
|
||||
title={t('ai.ignoreSuggestion')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onSelectTag(suggestion.tag)
|
||||
}}
|
||||
className="flex items-center px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-memento-blue"
|
||||
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
|
||||
>
|
||||
{isNewLabel ? (
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
) : (
|
||||
<Sparkles className="w-3 h-3 mr-1.5 opacity-60" />
|
||||
)}
|
||||
{suggestion.tag}
|
||||
{isNewLabel && (
|
||||
<span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export function HierarchicalNotebookSelector({
|
||||
if (!searchQuery) setIsOpen(false)
|
||||
}}
|
||||
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">
|
||||
{hasChildren ? (
|
||||
@@ -124,7 +124,7 @@ export function HierarchicalNotebookSelector({
|
||||
) : null}
|
||||
</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} />}
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function HierarchicalNotebookSelector({
|
||||
<div
|
||||
ref={triggerRef}
|
||||
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" />
|
||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||
@@ -192,7 +192,7 @@ export function HierarchicalNotebookSelector({
|
||||
style={getDropdownStyle()}
|
||||
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">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||
<input
|
||||
@@ -210,7 +210,7 @@ export function HierarchicalNotebookSelector({
|
||||
{renderTree(null)}
|
||||
</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">
|
||||
{notebooks.length} notebooks
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
|
||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||
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 { useRefresh } from '@/lib/use-refresh'
|
||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||
@@ -88,11 +88,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
||||
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||||
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||||
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldSuggestLabels && suggestNotebookId) {
|
||||
setAutoLabelOpen(true)
|
||||
}
|
||||
// Auto-trigger disabled — user opens manually from AI panel
|
||||
// if (shouldSuggestLabels && suggestNotebookId) {
|
||||
// setAutoLabelOpen(true)
|
||||
// }
|
||||
}, [shouldSuggestLabels, suggestNotebookId])
|
||||
|
||||
// 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) {
|
||||
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
|
||||
}, [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(() => {
|
||||
setControls({
|
||||
@@ -351,12 +401,22 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
|
||||
// Apply sort order to notes
|
||||
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 === '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 (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
|
||||
}, [notes, sortOrder])
|
||||
}, [notes, sortOrder, selectedTagIds, availableTags])
|
||||
|
||||
const sortedPinnedNotes = useMemo(() => {
|
||||
return sortedNotes.filter(n => n.isPinned)
|
||||
@@ -404,21 +464,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
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
|
||||
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>
|
||||
{currentNotebook && notebookPath.length > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2"
|
||||
style={{ color: 'var(--color-ink)', opacity: 1 }}
|
||||
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60"
|
||||
>
|
||||
{notebookPath.map((nb: any, i: number) => (
|
||||
<React.Fragment key={nb.id}>
|
||||
{i > 0 && <ChevronRight size={10} className="shrink-0" style={{ color: 'var(--color-concrete)' }} />}
|
||||
<span style={{ color: i === notebookPath.length - 1 ? 'var(--color-ink)' : 'var(--color-concrete)' }}>
|
||||
{i > 0 && <ChevronRight size={10} className="shrink-0 text-concrete" />}
|
||||
<span className={i === notebookPath.length - 1 ? 'text-ink' : 'text-concrete'}>
|
||||
{nb.name}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
@@ -557,6 +616,84 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
||||
</button>
|
||||
</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 className="px-12 flex-1 pb-20">
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LABEL_COLORS } from '@/lib/types'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
|
||||
interface LabelBadgeProps {
|
||||
label: string
|
||||
type?: 'ai' | 'user' // Optional: if provided, applies AI vs User styling
|
||||
type?: 'ai' | 'user'
|
||||
onRemove?: () => void
|
||||
variant?: 'default' | 'filter' | 'clickable'
|
||||
onClick?: () => void
|
||||
@@ -25,46 +22,54 @@ export function LabelBadge({
|
||||
isSelected = false,
|
||||
isDisabled = false,
|
||||
}: 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'
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
'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'
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
{onRemove && (
|
||||
<button
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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" />
|
||||
</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>
|
||||
<X size={8} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} 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 { cn } from '@/lib/utils'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -19,7 +19,6 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
|
||||
export interface LabelManagementDialogProps {
|
||||
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
@@ -77,9 +76,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
className="max-w-md"
|
||||
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
|
||||
onInteractOutside={(event) => {
|
||||
// Prevent dialog from closing when interacting with Sonner toasts
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSonnerElement =
|
||||
target.closest('[data-sonner-toast]') ||
|
||||
target.closest('[data-sonner-toaster]') ||
|
||||
@@ -88,12 +85,10 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
target.closest('[data-description]') ||
|
||||
target.closest('[data-title]') ||
|
||||
target.closest('[data-button]');
|
||||
|
||||
if (isSonnerElement) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -108,7 +103,6 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Add new label */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('labels.newLabelPlaceholder')}
|
||||
@@ -126,7 +120,6 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List labels */}
|
||||
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
|
||||
@@ -136,14 +129,21 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
labels.map((label) => {
|
||||
const colorClasses = LABEL_COLORS[label.color]
|
||||
const isEditing = editingColorId === label.id
|
||||
const isAI = label.type === 'ai'
|
||||
|
||||
return (
|
||||
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
|
||||
<div className="flex items-center gap-3 flex-1 relative">
|
||||
<Tag className={cn("h-4 w-4", colorClasses.text)} />
|
||||
{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>
|
||||
{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 && (
|
||||
<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">
|
||||
@@ -155,7 +155,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
|
||||
classes.bg,
|
||||
label.color === color ? 'border-gray-900 dark:border-gray-100 ring-2 ring-offset-1' : 'border-transparent'
|
||||
label.color === color ? 'border-foreground dark:border-foreground ring-2 ring-offset-1' : 'border-transparent'
|
||||
)}
|
||||
onClick={() => handleChangeColor(label.id, color)}
|
||||
title={color}
|
||||
|
||||
@@ -12,8 +12,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from './ui/dialog'
|
||||
import { Badge } from './ui/badge'
|
||||
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
|
||||
import { Tag, X, Plus, Palette, AlertCircle, Sparkles } from 'lucide-react'
|
||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
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 [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
// Sync selected labels with existingLabels prop
|
||||
useEffect(() => {
|
||||
setSelectedLabels(existingLabels)
|
||||
}, [existingLabels])
|
||||
|
||||
const handleAddLabel = async () => {
|
||||
const trimmed = newLabel.trim()
|
||||
setErrorMessage(null) // Clear previous error
|
||||
setErrorMessage(null)
|
||||
|
||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||
try {
|
||||
// Get existing label color or use random
|
||||
const existingLabel = labels.find(l => l.name === trimmed)
|
||||
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
||||
|
||||
@@ -113,9 +110,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onInteractOutside={(event) => {
|
||||
// Prevent dialog from closing when interacting with Sonner toasts
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
const isSonnerElement =
|
||||
target.closest('[data-sonner-toast]') ||
|
||||
target.closest('[data-sonner-toaster]') ||
|
||||
@@ -124,12 +119,10 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
target.closest('[data-description]') ||
|
||||
target.closest('[data-title]') ||
|
||||
target.closest('[data-button]');
|
||||
|
||||
if (isSonnerElement) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -144,7 +137,6 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
@@ -152,14 +144,13 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new label */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('labels.newLabelPlaceholder')}
|
||||
value={newLabel}
|
||||
onChange={(e) => {
|
||||
setNewLabel(e.target.value)
|
||||
setErrorMessage(null) // Clear error when typing
|
||||
setErrorMessage(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -173,20 +164,20 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selected labels */}
|
||||
{selectedLabels.length > 0 && (
|
||||
<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">
|
||||
{selectedLabels.map((label) => {
|
||||
const labelObj = labels.find(l => l.name === label)
|
||||
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
|
||||
const isEditing = editingColor === label
|
||||
const isAI = labelObj?.type === 'ai'
|
||||
|
||||
return (
|
||||
<div key={label} className="relative">
|
||||
{isEditing && labelObj ? (
|
||||
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2">
|
||||
<div className="absolute z-10 top-8 left-0 bg-popover border rounded-lg shadow-lg p-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||
const classes = LABEL_COLORS[color]
|
||||
@@ -196,7 +187,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||
classes.bg,
|
||||
labelObj.color === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
|
||||
labelObj.color === color ? 'border-foreground dark:border-foreground' : 'border-border'
|
||||
)}
|
||||
onClick={() => handleChangeColor(label, color)}
|
||||
title={color}
|
||||
@@ -206,27 +197,30 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Badge
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer pr-1 flex items-center gap-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
'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',
|
||||
isAI
|
||||
? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
|
||||
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
|
||||
)}
|
||||
onClick={() => setEditingColor(isEditing ? null : label)}
|
||||
>
|
||||
{isAI && <Sparkles className="h-3 w-3" />}
|
||||
<Palette className="h-3 w-3" />
|
||||
{label}
|
||||
<button
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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" />
|
||||
</button>
|
||||
</Badge>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -234,30 +228,30 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available labels from context */}
|
||||
{!loading && labels.length > 0 && (
|
||||
<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">
|
||||
{labels
|
||||
.filter(label => !selectedLabels.includes(label.name))
|
||||
.map((label) => {
|
||||
const colorClasses = LABEL_COLORS[label.color]
|
||||
const isAI = label.type === 'ai'
|
||||
|
||||
return (
|
||||
<Badge
|
||||
<button
|
||||
key={label.id}
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border,
|
||||
'hover:opacity-80'
|
||||
'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',
|
||||
isAI
|
||||
? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
|
||||
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
|
||||
)}
|
||||
onClick={() => handleSelectExisting(label.name)}
|
||||
>
|
||||
{isAI && <Sparkles className="h-3 w-3" />}
|
||||
{label.name}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -26,30 +26,8 @@ export function NoteContentArea() {
|
||||
return data.url
|
||||
}
|
||||
|
||||
if (state.noteType === 'richtext') {
|
||||
if (fullPage) {
|
||||
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) {
|
||||
// Markdown preview mode
|
||||
if (state.isMarkdown && state.showMarkdownPreview) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -68,7 +46,8 @@ export function NoteContentArea() {
|
||||
)
|
||||
}
|
||||
|
||||
if (state.noteType === 'markdown' || state.noteType === 'text') {
|
||||
// Markdown edit mode
|
||||
if (state.isMarkdown) {
|
||||
if (fullPage) {
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -93,12 +72,11 @@ export function NoteContentArea() {
|
||||
)
|
||||
}
|
||||
|
||||
// Dialog mode
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
dir="auto"
|
||||
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
|
||||
placeholder={t('notes.takeNoteMarkdown') || t('notes.takeNote')}
|
||||
value={state.content}
|
||||
onChange={(e) => actions.setContent(e.target.value)}
|
||||
disabled={readOnly}
|
||||
@@ -118,62 +96,35 @@ export function NoteContentArea() {
|
||||
)
|
||||
}
|
||||
|
||||
// Checklist mode
|
||||
// Richtext mode (default)
|
||||
if (fullPage) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{state.checkItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-2 group">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
||||
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 className="fullpage-editor">
|
||||
<RichTextEditor
|
||||
content={state.content}
|
||||
onChange={(v: string) => actions.setContent(v)}
|
||||
className="min-h-[280px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{state.checkItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-2 group">
|
||||
<Checkbox
|
||||
checked={item.checked}
|
||||
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
||||
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>
|
||||
<RichTextEditor
|
||||
content={state.content}
|
||||
onChange={actions.setContent}
|
||||
className="min-h-[200px]"
|
||||
onImageUpload={uploadImageFile}
|
||||
/>
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
addedTags={state.labels}
|
||||
isAnalyzing={state.isAnalyzingSuggestions}
|
||||
onSelectTag={actions.handleSelectGhostTag}
|
||||
onDismissTag={actions.handleDismissGhostTag}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
|
||||
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 { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -48,7 +48,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
}, [session?.user?.id])
|
||||
|
||||
// Core content state
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
const [content, setContent] = useState(note.content)
|
||||
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 [isSaving, setIsSaving] = useState(false)
|
||||
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
||||
const [noteType, setNoteType] = useState<NoteType>(note.type)
|
||||
const isMarkdown = noteType === 'markdown'
|
||||
const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown')
|
||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
|
||||
|
||||
// Refs
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const prevNoteRef = useRef(note)
|
||||
|
||||
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
|
||||
useEffect(() => {
|
||||
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
|
||||
setTitle(note.title || '')
|
||||
@@ -80,40 +76,54 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setLinks(note.links || [])
|
||||
setColor(note.color)
|
||||
setSize(note.size || 'small')
|
||||
setNoteType(note.type)
|
||||
setIsMarkdown(note.type === 'markdown')
|
||||
setShowMarkdownPreview(note.type === 'markdown')
|
||||
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||
}
|
||||
prevNoteRef.current = note
|
||||
}, [note])
|
||||
|
||||
// Update context notebookId when note changes
|
||||
useEffect(() => {
|
||||
setContextNotebookId(note.notebookId || null)
|
||||
}, [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({
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
content: content,
|
||||
notebookId: note.notebookId,
|
||||
enabled: noteType !== 'checklist' && autoLabelingEnabled
|
||||
enabled: autoTaggingEnabled
|
||||
})
|
||||
|
||||
// Reminder state
|
||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
||||
note.reminder ? new Date(note.reminder as unknown as string) : null
|
||||
)
|
||||
|
||||
// Link state
|
||||
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
|
||||
// Title suggestions state
|
||||
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
|
||||
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
|
||||
|
||||
// Reformulation state
|
||||
const [isReformulating, setIsReformulating] = useState(false)
|
||||
const [reformulationModal, setReformulationModal] = useState<{
|
||||
originalText: string
|
||||
@@ -121,38 +131,28 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
option: string
|
||||
} | null>(null)
|
||||
|
||||
// AI processing state
|
||||
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||
const [aiOpen, setAiOpen] = useState(false)
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
// fullPage — auto title suggestions
|
||||
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
|
||||
content,
|
||||
enabled: fullPage && !title && !dismissedTitleSuggestions,
|
||||
})
|
||||
|
||||
// Wire autoTitleSuggestions into state so NoteTitleBlock can display them
|
||||
useEffect(() => {
|
||||
if (autoTitleSuggestions.length > 0) {
|
||||
setTitleSuggestions(autoTitleSuggestions)
|
||||
}
|
||||
}, [autoTitleSuggestions])
|
||||
|
||||
// Track previous content for copilot action undo
|
||||
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
||||
|
||||
// Memory Echo Connections state
|
||||
const [comparisonNotes, setComparisonNotes] = 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 filteredSuggestions = suggestions.filter(s => {
|
||||
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(() => {
|
||||
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
|
||||
if (!items) return
|
||||
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 })
|
||||
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||
}, [t, noteType])
|
||||
}, [t, isMarkdown])
|
||||
|
||||
// Auto-grow textarea as content grows
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
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'
|
||||
}, [content])
|
||||
|
||||
// Also auto-grow when switching FROM preview TO edit mode
|
||||
useEffect(() => {
|
||||
if (showMarkdownPreview) return // we're in preview, textarea not mounted
|
||||
// Defer one frame so the textarea is in the DOM
|
||||
if (showMarkdownPreview) return
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
@@ -234,7 +230,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleRemoveImage = (index: number) => {
|
||||
const removedUrl = images[index]
|
||||
setImages(images.filter((_, i) => i !== index))
|
||||
// Track removed images for cleanup on save
|
||||
if (removedUrl) {
|
||||
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||
}
|
||||
@@ -267,9 +262,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||
const extracted = !isMarkdown ? extractImagesFromHTML(content) : [];
|
||||
return Array.from(new Set([...images, ...extracted]));
|
||||
}, [images, content, noteType]);
|
||||
}, [images, content, isMarkdown]);
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
const fullContentForAI = [
|
||||
@@ -301,7 +296,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
|
||||
const data = await response.json()
|
||||
setTitleSuggestions(data.suggestions || [])
|
||||
// Auto-apply first title for dialog mode (fullPage shows suggestions UI instead)
|
||||
if (!fullPage && data.suggestions?.[0]?.title) {
|
||||
setTitle(data.suggestions[0].title)
|
||||
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'))
|
||||
|
||||
setContent(data.transformedText)
|
||||
setNoteType('markdown')
|
||||
setIsMarkdown(true)
|
||||
setShowMarkdownPreview(false)
|
||||
|
||||
toast.success(t('ai.transformSuccess'))
|
||||
@@ -500,13 +494,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
const handleApplyRefactor = () => {
|
||||
if (!reformulationModal) return
|
||||
|
||||
const selectedText = window.getSelection()?.toString()
|
||||
if (selectedText) {
|
||||
setContent(reformulationModal.reformulatedText)
|
||||
} else {
|
||||
setContent(reformulationModal.reformulatedText)
|
||||
}
|
||||
|
||||
setContent(reformulationModal.reformulatedText)
|
||||
setReformulationModal(null)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
}
|
||||
@@ -536,35 +524,27 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
console.log('[SAVE] handleSave called, note.id:', note.id)
|
||||
setIsSaving(true)
|
||||
try {
|
||||
console.log('[SAVE] Calling updateNote...')
|
||||
const result = await updateNote(note.id, {
|
||||
title: title.trim() || null,
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||
content,
|
||||
checkItems: null,
|
||||
labels,
|
||||
images,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown: noteType === 'markdown',
|
||||
type: noteType,
|
||||
isMarkdown,
|
||||
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||
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 }
|
||||
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
}
|
||||
await refreshLabels()
|
||||
// Notify parent with the freshly-saved note so it can update its local state immediately
|
||||
onNoteSaved?.(result)
|
||||
// Invalidate note and notes list cache
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
@@ -607,6 +587,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
|
||||
if (!tagExists) {
|
||||
setLabels(prev => [...prev, tag])
|
||||
setIsDirty(true)
|
||||
|
||||
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
@@ -621,11 +602,16 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
}
|
||||
|
||||
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) => {
|
||||
setLabels(labels.filter(l => l !== label))
|
||||
setIsDirty(true)
|
||||
}
|
||||
|
||||
const handleMakeCopy = async () => {
|
||||
@@ -638,54 +624,42 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
labels: labels,
|
||||
images: images,
|
||||
links: links,
|
||||
isMarkdown: noteType === 'markdown',
|
||||
type: noteType,
|
||||
isMarkdown,
|
||||
type: isMarkdown ? 'markdown' : 'richtext',
|
||||
size: size,
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
// Invalidate notes list cache for current notebook
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
triggerRefresh()
|
||||
// Note: onClose is handled by the composition component
|
||||
} catch (error) {
|
||||
console.error('Failed to copy note:', error)
|
||||
toast.error(t('notes.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// Save in place (fullPage) — without closing
|
||||
const handleSaveInPlace = async () => {
|
||||
console.log('[SAVE] handleSaveInPlace called, note.id:', note.id, 'content length:', content.length, 'title:', title.substring(0, 50))
|
||||
setIsSaving(true)
|
||||
try {
|
||||
console.log('[SAVE] Calling updateNote with note.id:', note.id, '| content len:', content.length, '| title:', title.substring(0, 30))
|
||||
const updatePayload = {
|
||||
title: title.trim() || null,
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||
content,
|
||||
checkItems: null,
|
||||
labels,
|
||||
images,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown: noteType === 'markdown',
|
||||
type: noteType,
|
||||
isMarkdown,
|
||||
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||
size,
|
||||
}
|
||||
console.log('[SAVE] payload.content:', JSON.stringify(updatePayload.content)?.substring(0, 100))
|
||||
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 }
|
||||
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
}
|
||||
await refreshLabels()
|
||||
// Notify parent with the freshly-saved note so it can update its local state immediately
|
||||
onNoteSaved?.(result)
|
||||
// Invalidate note and notes list cache
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||
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(() => {
|
||||
if (!fullPage) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -712,7 +685,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [fullPage, isSaving])
|
||||
|
||||
// Build state object
|
||||
const state: NoteEditorState = useMemo(() => ({
|
||||
title,
|
||||
content,
|
||||
@@ -723,7 +695,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
newLabel,
|
||||
color: color as NoteColor,
|
||||
size,
|
||||
noteType,
|
||||
showMarkdownPreview,
|
||||
removedImageUrls,
|
||||
isSaving,
|
||||
@@ -750,7 +721,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
allImages,
|
||||
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,
|
||||
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||
@@ -758,10 +729,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
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 = {
|
||||
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
|
||||
setDismissedTitleSuggestions,
|
||||
@@ -782,8 +749,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setLinks,
|
||||
handleAddLink,
|
||||
handleRemoveLink,
|
||||
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
|
||||
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
|
||||
setIsMarkdown: (m) => { setIsMarkdown(m); setIsDirty(true) },
|
||||
setColor: (c) => { setColor(c); setIsDirty(true) },
|
||||
setSize: (s) => { setSize(s); setIsDirty(true) },
|
||||
setShowReminderDialog,
|
||||
@@ -815,7 +782,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
||||
setPreviousContentForCopilot,
|
||||
}
|
||||
|
||||
|
||||
const value: NoteEditorContextValue = useMemo(() => ({
|
||||
note,
|
||||
readOnly,
|
||||
@@ -841,4 +807,4 @@ export function useNoteEditorContext() {
|
||||
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,8 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
||||
} : undefined}
|
||||
lastActionApplied={state.previousContentForCopilot !== null}
|
||||
notebooks={notebooks}
|
||||
notebookId={note.notebookId ?? undefined}
|
||||
notebookName={notebooks.find(nb => nb.id === note.notebookId)?.name ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -14,23 +14,30 @@ import { format } from 'date-fns'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Note } from '@/lib/types'
|
||||
import { GhostTags } from '@/components/ghost-tags'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
|
||||
interface NoteEditorFullPageProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
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 getLabelType = (name: string): 'ai' | 'user' => {
|
||||
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
|
||||
return (found as any)?.type === 'ai' ? 'ai' : 'user'
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── outer container ── */}
|
||||
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
|
||||
|
||||
{/* ── 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 */}
|
||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
|
||||
@@ -51,6 +58,28 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
|
||||
{/* Title */}
|
||||
<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>
|
||||
|
||||
{/* Hero image — show first note image if present */}
|
||||
@@ -83,12 +112,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||
onApplyToNote={(nc: string) => {
|
||||
actions.setPreviousContentForCopilot(state.content)
|
||||
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}
|
||||
lastActionApplied={state.previousContentForCopilot !== null}
|
||||
notebooks={notebooks}
|
||||
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'}
|
||||
notebookId={note.notebookId ?? undefined}
|
||||
notebookName={notebookName ?? undefined}
|
||||
diagramInsertFormat={state.isMarkdown ? 'markdown' : 'html'}
|
||||
onGenerateTitle={async () => {
|
||||
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||
|
||||
@@ -7,7 +7,6 @@ import { LabelBadge } from '@/components/label-badge'
|
||||
import { GhostTags } from '@/components/ghost-tags'
|
||||
import { EditorImages } from '@/components/editor-images'
|
||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||
import { NoteTypeSelector } from '@/components/note-type-selector'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -44,32 +43,28 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
|
||||
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; noteType: string } | null>(null)
|
||||
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||
|
||||
const handleConvertToRichtext = async () => {
|
||||
if (isConverting || !state.content.trim()) return
|
||||
setIsConverting(true)
|
||||
|
||||
// Capture snapshot BEFORE converting
|
||||
const snapshot = { content: state.content, noteType: state.noteType }
|
||||
const snapshot = { content: state.content, isMarkdown: state.isMarkdown }
|
||||
undoSnapshotRef.current = snapshot
|
||||
|
||||
try {
|
||||
let html: string
|
||||
if (state.noteType === 'markdown') {
|
||||
// Proper markdown → HTML via marked (no AI needed)
|
||||
if (state.isMarkdown) {
|
||||
const { marked } = await import('marked')
|
||||
html = await marked(state.content, { async: false }) as string
|
||||
} else {
|
||||
// Plain text → wrap paragraphs in <p> tags
|
||||
html = state.content
|
||||
.split(/\n{2,}/)
|
||||
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
|
||||
.join('')
|
||||
}
|
||||
actions.setContent(html)
|
||||
actions.setNoteType('richtext')
|
||||
actions.setIsMarkdown(false)
|
||||
|
||||
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
|
||||
duration: 8000,
|
||||
@@ -79,7 +74,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
const snap = undoSnapshotRef.current
|
||||
if (!snap) return
|
||||
actions.setContent(snap.content)
|
||||
actions.setNoteType(snap.noteType as any)
|
||||
if (snap.isMarkdown) actions.setIsMarkdown(true)
|
||||
undoSnapshotRef.current = null
|
||||
toast.info(t('ai.undoApplied') || 'Conversion undone')
|
||||
},
|
||||
@@ -94,8 +89,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
|
||||
if (mode === 'fullPage') {
|
||||
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">
|
||||
{/* Left: back */}
|
||||
<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">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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>
|
||||
</button>
|
||||
|
||||
{/* Right: status + type + AI + Info */}
|
||||
<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">
|
||||
{state.isSaving
|
||||
? <><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></>}
|
||||
</span>
|
||||
|
||||
{/* Note type */}
|
||||
<NoteTypeSelector
|
||||
value={state.noteType}
|
||||
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* Preview toggle — icon only */}
|
||||
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
|
||||
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
|
||||
@@ -139,8 +123,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Convert to Rich Text — icon only */}
|
||||
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
|
||||
{state.isMarkdown && !readOnly && (
|
||||
<button
|
||||
title={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>
|
||||
)}
|
||||
|
||||
{/* AI — icon only */}
|
||||
<button
|
||||
title="AI Assistant"
|
||||
aria-label="Ouvrir l'assistant IA"
|
||||
@@ -171,7 +153,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
|
||||
{/* Save — icon only */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
|
||||
@@ -189,7 +170,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Share button */}
|
||||
{!readOnly && (
|
||||
<button
|
||||
title="Partager la note"
|
||||
@@ -201,8 +181,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Three-dot options menu */}
|
||||
{!readOnly && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -229,7 +207,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Share Dialog portal */}
|
||||
{shareOpen && (
|
||||
<NoteShareDialog
|
||||
noteId={note.id}
|
||||
@@ -238,7 +215,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info panel toggle — rightmost, icon only */}
|
||||
<button
|
||||
aria-label="Informations du document"
|
||||
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
||||
@@ -256,32 +232,26 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Dialog toolbar
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!readOnly && (
|
||||
<>
|
||||
{/* Reminder */}
|
||||
<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')}>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
{/* Add Image */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Add Link */}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
|
||||
|
||||
{state.noteType === 'markdown' && (
|
||||
{state.isMarkdown && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
||||
@@ -289,17 +259,13 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* AI Copilot */}
|
||||
{state.noteType !== 'checklist' && (
|
||||
<Button variant="ghost" size="sm"
|
||||
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')}
|
||||
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">IA Note</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm"
|
||||
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')}
|
||||
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">IA Note</span>
|
||||
</Button>
|
||||
|
||||
{/* Size Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Color Picker */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Label Manager */}
|
||||
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
|
||||
</>
|
||||
)}
|
||||
@@ -394,4 +358,4 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,12 @@ import { GhostTags } from '../ghost-tags'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
@@ -17,6 +22,7 @@ export function NoteMetadataSection() {
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
type={getLabelType(label)}
|
||||
onRemove={() => actions.handleRemoveLabel(label)}
|
||||
/>
|
||||
))}
|
||||
@@ -24,7 +30,7 @@ export function NoteMetadataSection() {
|
||||
)}
|
||||
|
||||
{/* Ghost Tags - only show in dialog mode */}
|
||||
{!readOnly && state.noteType !== 'richtext' && (
|
||||
{!readOnly && !state.isMarkdown && (
|
||||
<GhostTags
|
||||
suggestions={state.filteredSuggestions}
|
||||
addedTags={state.labels}
|
||||
|
||||
@@ -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 { TagSuggestion } from '@/lib/ai/types'
|
||||
|
||||
// State interface - all local state from NoteEditor
|
||||
export interface NoteEditorState {
|
||||
// Core content state
|
||||
title: string
|
||||
content: string
|
||||
checkItems: CheckItem[]
|
||||
@@ -14,15 +12,12 @@ export interface NoteEditorState {
|
||||
newLabel: string
|
||||
color: NoteColor
|
||||
size: NoteSize
|
||||
noteType: NoteType
|
||||
|
||||
// UI state
|
||||
showMarkdownPreview: boolean
|
||||
removedImageUrls: string[]
|
||||
isSaving: boolean
|
||||
isDirty: boolean
|
||||
|
||||
// AI state
|
||||
isProcessingAI: boolean
|
||||
aiOpen: boolean
|
||||
infoOpen: boolean
|
||||
@@ -37,107 +32,84 @@ export interface NoteEditorState {
|
||||
} | null
|
||||
previousContentForCopilot: string | null
|
||||
|
||||
// Reminder state
|
||||
showReminderDialog: boolean
|
||||
currentReminder: Date | null
|
||||
|
||||
// Link dialog state
|
||||
showLinkDialog: boolean
|
||||
linkUrl: string
|
||||
|
||||
// Memory Echo Connections
|
||||
comparisonNotes: Array<Partial<Note>>
|
||||
fusionNotes: Array<Partial<Note>>
|
||||
|
||||
// Ghost tags
|
||||
dismissedTags: string[]
|
||||
|
||||
// Tag suggestions (from auto-tagging)
|
||||
filteredSuggestions: TagSuggestion[]
|
||||
isAnalyzingSuggestions: boolean
|
||||
|
||||
// Context-derived values
|
||||
isMarkdown: boolean
|
||||
allImages: string[]
|
||||
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
||||
}
|
||||
|
||||
// Actions interface - all handlers from NoteEditor
|
||||
export interface NoteEditorActions {
|
||||
// Title actions
|
||||
setTitle: (title: string) => void
|
||||
setDismissedTitleSuggestions: (dismissed: boolean) => void
|
||||
|
||||
// Content actions
|
||||
setContent: (content: string) => void
|
||||
|
||||
// CheckItems actions
|
||||
setCheckItems: (items: CheckItem[]) => void
|
||||
handleCheckItem: (id: string) => void
|
||||
handleUpdateCheckItem: (id: string, text: string) => void
|
||||
handleAddCheckItem: () => void
|
||||
handleRemoveCheckItem: (id: string) => void
|
||||
|
||||
// Labels actions
|
||||
setLabels: (labels: string[]) => void
|
||||
handleSelectGhostTag: (tag: string) => void
|
||||
handleDismissGhostTag: (tag: string) => void
|
||||
handleRemoveLabel: (label: string) => void
|
||||
|
||||
// Images actions
|
||||
setImages: (images: string[]) => void
|
||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
handleRemoveImage: (index: number) => void
|
||||
uploadImageFile: (file: File) => Promise<string>
|
||||
|
||||
// Links actions
|
||||
setLinks: (links: LinkMetadata[]) => void
|
||||
handleAddLink: () => Promise<void>
|
||||
handleRemoveLink: (index: number) => void
|
||||
|
||||
// Note properties
|
||||
setNoteType: (type: NoteType) => void
|
||||
setShowMarkdownPreview: (show: boolean) => void
|
||||
setIsMarkdown: (markdown: boolean) => void
|
||||
setColor: (color: NoteColor) => void
|
||||
setSize: (size: NoteSize) => void
|
||||
|
||||
// Reminder actions
|
||||
setShowReminderDialog: (show: boolean) => void
|
||||
setCurrentReminder: (date: Date | null) => void
|
||||
handleReminderSave: (date: Date) => Promise<void>
|
||||
handleRemoveReminder: () => Promise<void>
|
||||
|
||||
// Link dialog
|
||||
setShowLinkDialog: (show: boolean) => void
|
||||
setLinkUrl: (url: string) => void
|
||||
|
||||
// Title suggestions
|
||||
handleGenerateTitles: () => Promise<void>
|
||||
handleSelectTitle: (title: string) => void
|
||||
|
||||
// Reformulation
|
||||
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
|
||||
handleApplyRefactor: () => void
|
||||
|
||||
// AI Direct handlers
|
||||
handleClarifyDirect: () => Promise<void>
|
||||
handleShortenDirect: () => Promise<void>
|
||||
handleImproveDirect: () => Promise<void>
|
||||
handleTransformMarkdown: () => Promise<void>
|
||||
|
||||
// Save actions
|
||||
handleSave: () => Promise<void>
|
||||
handleSaveInPlace: () => Promise<void>
|
||||
handleMakeCopy: () => Promise<void>
|
||||
|
||||
// Memory Echo
|
||||
setComparisonNotes: (notes: Array<Partial<Note>>) => void
|
||||
setFusionNotes: (notes: Array<Partial<Note>>) => void
|
||||
|
||||
// Modal states
|
||||
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
|
||||
|
||||
// State setters
|
||||
setIsDirty: (dirty: boolean) => void
|
||||
setAiOpen: (open: boolean) => void
|
||||
setInfoOpen: (open: boolean) => void
|
||||
@@ -147,28 +119,14 @@ export interface NoteEditorActions {
|
||||
setPreviousContentForCopilot: (content: string | null) => void
|
||||
}
|
||||
|
||||
// Context value - combines state + actions + note reference
|
||||
export interface NoteEditorContextValue {
|
||||
// The current note (external source of truth)
|
||||
note: Note
|
||||
|
||||
// Read-only flag
|
||||
readOnly: boolean
|
||||
|
||||
// FullPage flag
|
||||
fullPage: boolean
|
||||
|
||||
// All state
|
||||
state: NoteEditorState
|
||||
|
||||
// All actions
|
||||
actions: NoteEditorActions
|
||||
|
||||
// Computed values from contexts
|
||||
notebooks: Array<{ id: string; name: string }>
|
||||
globalLabels: Array<{ name: string }>
|
||||
|
||||
// Refs
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { useRefresh } from '@/lib/use-refresh'
|
||||
import { motion, AnimatePresence } from 'motion/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 { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||
@@ -275,7 +276,7 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
|
||||
return (
|
||||
<div
|
||||
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 */}
|
||||
<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({
|
||||
notes,
|
||||
onOpen,
|
||||
@@ -322,6 +335,7 @@ export function NotesEditorialView({
|
||||
}: NotesEditorialViewProps) {
|
||||
const { t } = useLanguage()
|
||||
const { data: session } = useSession()
|
||||
const { data: allLabels } = useLabelsQuery()
|
||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -374,6 +388,13 @@ export function NotesEditorialView({
|
||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
|
||||
<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 ? (
|
||||
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
|
||||
{excerpt}
|
||||
|
||||
@@ -42,8 +42,8 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
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',
|
||||
isActive(section.href)
|
||||
? 'border-[#D4A373] text-[#1C1C1C]'
|
||||
: 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]'
|
||||
? 'border-[#D4A373] text-ink'
|
||||
: 'border-transparent text-muted-ink hover:text-ink'
|
||||
)}
|
||||
>
|
||||
{section.icon}
|
||||
|
||||
@@ -23,9 +23,12 @@ import {
|
||||
Bell,
|
||||
Pencil,
|
||||
Clock,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
@@ -151,7 +154,7 @@ function SidebarCarnetItem({
|
||||
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
||||
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',
|
||||
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 && (
|
||||
@@ -163,9 +166,9 @@ function SidebarCarnetItem({
|
||||
)}
|
||||
<div className={cn(
|
||||
'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-white/60 text-ink border-border'
|
||||
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
|
||||
)}>
|
||||
{carnet.initial}
|
||||
</div>
|
||||
@@ -260,6 +263,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
||||
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
|
||||
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 [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
@@ -581,7 +597,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{user?.image ? (
|
||||
<Avatar className="size-10 ring-1 ring-border/60">
|
||||
<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>
|
||||
) : (
|
||||
<span>{initial}</span>
|
||||
@@ -621,10 +637,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Notification bell + Notebooks / Agents toggle */}
|
||||
<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 />
|
||||
<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
|
||||
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')}
|
||||
@@ -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',
|
||||
isInboxActive
|
||||
? '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} />
|
||||
</div>
|
||||
@@ -784,14 +806,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'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(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
||||
isActive
|
||||
? '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} />
|
||||
</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',
|
||||
searchParams.get('shared') === '1' && pathname === '/'
|
||||
? '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'} />
|
||||
@@ -823,7 +845,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
|
||||
<Link
|
||||
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" />
|
||||
<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',
|
||||
pathname.startsWith('/settings')
|
||||
? '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'} />
|
||||
|
||||
@@ -20,7 +20,9 @@ toolRegistry.register({
|
||||
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'),
|
||||
}),
|
||||
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 {
|
||||
// Keyword fallback search using Prisma
|
||||
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface Notebook {
|
||||
parentId: string | null;
|
||||
trashedAt?: Date | null;
|
||||
userId: string;
|
||||
isPrivate?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
notes?: Note[];
|
||||
|
||||
Reference in New Issue
Block a user