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 (
|
return (
|
||||||
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
<ProvidersWrapper initialLanguage={initialLanguage} initialTranslations={initialTranslations}>
|
||||||
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
|
||||||
<div className="flex h-screen overflow-hidden bg-memento-desk">
|
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
|
||||||
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
<Suspense fallback={<div className="hidden w-80 shrink-0 md:block" />}>
|
||||||
<Sidebar user={session?.user} />
|
<Sidebar user={session?.user} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper">
|
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto scroll-smooth bg-memento-paper dark:bg-background">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -932,6 +932,11 @@ export async function updateNote(id: string, data: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.labels !== undefined) {
|
||||||
|
const refreshed = await prisma.note.findUnique({ where: { id } })
|
||||||
|
if (refreshed) return parseNote(refreshed)
|
||||||
|
}
|
||||||
|
|
||||||
return parseNote(note)
|
return parseNote(note)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating note:', error)
|
console.error('Error updating note:', error)
|
||||||
|
|||||||
@@ -263,13 +263,21 @@ Content: ${noteContext.content || '(empty)'}
|
|||||||
Focus ONLY on this note unless asked otherwise.`
|
Focus ONLY on this note unless asked otherwise.`
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemPrompt = `${prompts.system}\n${copilotContext}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.`
|
// Notebook scope directive — tells the AI to stay within the selected notebook
|
||||||
|
let notebookScopeDirective = ''
|
||||||
|
if (notebookId) {
|
||||||
|
const scopedNotebook = await prisma.notebook.findUnique({ where: { id: notebookId }, select: { name: true } }).catch(() => null)
|
||||||
|
const notebookName = scopedNotebook?.name || notebookId
|
||||||
|
notebookScopeDirective = `\n\n## NOTEBOOK SCOPE\nThe user has scoped this conversation to the notebook "${notebookName}". When using the note_search tool, ALWAYS pass notebookId="${notebookId}" to restrict results to this notebook. Only reference notes from this notebook unless the user explicitly asks otherwise.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = `${prompts.system}\n${copilotContext}${notebookScopeDirective}\n\n${contextBlock}\n\n## LANGUAGE RULE (MANDATORY)\nYou MUST respond in ${lang === 'en' ? 'English' : lang === 'fr' ? 'French' : lang === 'fa' ? 'Persian (Farsi)' : lang === 'es' ? 'Spanish' : 'English'}.`
|
||||||
|
|
||||||
// 6. Execute stream
|
// 6. Execute stream
|
||||||
const sysConfig = await getSystemConfig()
|
const sysConfig = await getSystemConfig()
|
||||||
const chatTools = noteContext
|
const chatTools = noteContext
|
||||||
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
|
? toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, webOnly: true })
|
||||||
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch })
|
: toolRegistry.buildToolsForChat({ userId, config: sysConfig, webSearch, notebookId: notebookId || undefined })
|
||||||
|
|
||||||
const provider = getChatProvider(sysConfig)
|
const provider = getChatProvider(sysConfig)
|
||||||
const result = await streamText({
|
const result = await streamText({
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
--color-background-dark: #202020;
|
--color-background-dark: #202020;
|
||||||
|
|
||||||
/* Design tokens from architectural-grid 10 */
|
/* Design tokens from architectural-grid 10 */
|
||||||
--color-ink: #1C1C1C;
|
--color-ink: var(--ink);
|
||||||
--color-paper: #F2F0E9;
|
--color-paper: var(--paper);
|
||||||
--color-muted-ink: rgba(28, 28, 28, 0.6);
|
--color-muted-ink: var(--muted-ink);
|
||||||
--color-concrete: #8D8D8D;
|
--color-concrete: var(--concrete);
|
||||||
--color-blueprint: #75B2D6;
|
--color-blueprint: #75B2D6;
|
||||||
--color-ochre: #D4A373;
|
--color-ochre: #D4A373;
|
||||||
--color-sage: #A3B18A;
|
--color-sage: #A3B18A;
|
||||||
@@ -183,6 +183,7 @@ html:not(.dark) .memento-active-nav {
|
|||||||
--ink: var(--foreground);
|
--ink: var(--foreground);
|
||||||
--paper: var(--background);
|
--paper: var(--background);
|
||||||
--muted-ink: var(--muted-foreground);
|
--muted-ink: var(--muted-foreground);
|
||||||
|
--concrete: #8D8D8D;
|
||||||
--ai-accent: #ACB995;
|
--ai-accent: #ACB995;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,6 +425,8 @@ html.dark {
|
|||||||
--sidebar-accent-foreground: #1C1C1C;
|
--sidebar-accent-foreground: #1C1C1C;
|
||||||
--sidebar-border: rgba(28, 28, 28, 0.1);
|
--sidebar-border: rgba(28, 28, 28, 0.1);
|
||||||
--sidebar-ring: rgba(28, 28, 28, 0.35);
|
--sidebar-ring: rgba(28, 28, 28, 0.35);
|
||||||
|
--thumb-lightness-1: 94%;
|
||||||
|
--thumb-lightness-2: 87%;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='light'].dark {
|
[data-theme='light'].dark {
|
||||||
@@ -487,6 +490,9 @@ html.dark {
|
|||||||
--sidebar-accent-foreground: #ffffff;
|
--sidebar-accent-foreground: #ffffff;
|
||||||
--sidebar-border: #3d3d3d;
|
--sidebar-border: #3d3d3d;
|
||||||
--sidebar-ring: #a8a29e;
|
--sidebar-ring: #a8a29e;
|
||||||
|
--thumb-lightness-1: 15%;
|
||||||
|
--thumb-lightness-2: 10%;
|
||||||
|
--concrete: #A0A0A0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='midnight'] {
|
[data-theme='midnight'] {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={cn(
|
<aside className={cn(
|
||||||
"fixed bottom-20 right-6 border border-border/40 bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
|
"fixed bottom-20 right-6 border border-border/40 bg-memento-paper dark:bg-background flex flex-col z-40 shadow-2xl rounded-2xl overflow-hidden transition-all duration-300",
|
||||||
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
|
isExpanded ? "w-[80vw] h-[85vh] max-w-[1200px]" : "h-[700px] max-h-[85vh] w-[360px]"
|
||||||
)}>
|
)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -242,7 +242,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
<div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||||
<p className="text-sm text-foreground leading-relaxed">
|
<p className="text-sm text-foreground leading-relaxed">
|
||||||
{t('ai.welcomeMsg')}
|
{t('ai.welcomeMsg')}
|
||||||
</p>
|
</p>
|
||||||
@@ -269,7 +269,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
'max-w-[85%] p-3.5 rounded-2xl text-sm leading-relaxed shadow-sm',
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'bg-memento-blue text-white rounded-tr-sm'
|
? 'bg-memento-blue text-white rounded-tr-sm'
|
||||||
: 'bg-background border border-border/50 rounded-tl-sm text-foreground',
|
: 'bg-memento-paper dark:bg-background border border-border/50 rounded-tl-sm text-foreground',
|
||||||
)}>
|
)}>
|
||||||
{msg.role === 'assistant'
|
{msg.role === 'assistant'
|
||||||
? <MarkdownContent content={text} />
|
? <MarkdownContent content={text} />
|
||||||
@@ -284,7 +284,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
<div className="w-8 h-8 rounded-full bg-memento-blue/10 text-memento-blue flex items-center justify-center flex-shrink-0 border border-memento-blue/20">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
<div className="bg-memento-paper dark:bg-background border border-border/50 p-3.5 rounded-2xl rounded-tl-sm shadow-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +350,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input Area & Tone Controls (Only in Chat tab) */}
|
{/* Input Area & Tone Controls (Only in Chat tab) */}
|
||||||
<div className={cn("p-4 border-t border-border/40 bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
|
<div className={cn("p-4 border-t border-border/40 bg-memento-paper dark:bg-background shrink-0", activeTab !== 'chat' && "hidden")}>
|
||||||
{/* Context Scope */}
|
{/* Context Scope */}
|
||||||
<div className="mb-3 space-y-2">
|
<div className="mb-3 space-y-2">
|
||||||
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ml-1">Source du Contexte</span>
|
<span className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground block ml-1">Source du Contexte</span>
|
||||||
@@ -403,7 +403,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
|
"py-1 rounded-md border text-[10px] font-medium transition-all flex flex-col items-center justify-center gap-0.5",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm"
|
? "border-memento-blue bg-memento-blue/10 text-memento-blue shadow-sm"
|
||||||
: "border-border/60 bg-background text-muted-foreground hover:bg-muted hover:border-border"
|
: "border-border/60 bg-memento-paper dark:bg-background text-muted-foreground hover:bg-muted hover:border-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
@@ -415,7 +415,7 @@ export function AIChat({ showFloatingTrigger = true }: { showFloatingTrigger?: b
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text Input */}
|
{/* Text Input */}
|
||||||
<div className="relative bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm">
|
<div className="relative bg-memento-paper dark:bg-background border border-border/60 rounded-xl p-1 focus-within:border-memento-blue focus-within:ring-1 focus-within:ring-memento-blue/20 transition-all shadow-sm">
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
|
className="w-full bg-transparent border-none focus:ring-0 resize-none text-sm text-foreground placeholder:text-muted-foreground/70 p-2 min-h-[60px] max-h-[120px]"
|
||||||
placeholder={t('ai.chatPlaceholder')}
|
placeholder={t('ai.chatPlaceholder')}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Button } from './ui/button'
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,11 +9,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from './ui/dialog'
|
} from './ui/dialog'
|
||||||
import { Checkbox } from './ui/checkbox'
|
import { Sparkles, CheckCircle2, Loader2, Tag } from 'lucide-react'
|
||||||
import { Tag, Loader2, Sparkles, CheckCircle2 } from 'lucide-react'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
|
import type { AutoLabelSuggestion, SuggestedLabel } from '@/lib/ai/services'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface AutoLabelSuggestionDialogProps {
|
interface AutoLabelSuggestionDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -35,12 +34,10 @@ export function AutoLabelSuggestionDialog({
|
|||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set())
|
const [selectedLabels, setSelectedLabels] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
// Fetch suggestions when dialog opens with a notebook
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && notebookId) {
|
if (open && notebookId) {
|
||||||
fetchSuggestions()
|
fetchSuggestions()
|
||||||
} else {
|
} else {
|
||||||
// Reset state when closing
|
|
||||||
setSuggestions(null)
|
setSuggestions(null)
|
||||||
setSelectedLabels(new Set())
|
setSelectedLabels(new Set())
|
||||||
}
|
}
|
||||||
@@ -65,12 +62,13 @@ export function AutoLabelSuggestionDialog({
|
|||||||
|
|
||||||
if (data.success && data.data) {
|
if (data.success && data.data) {
|
||||||
setSuggestions(data.data)
|
setSuggestions(data.data)
|
||||||
// Select all labels by default
|
|
||||||
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
|
const allLabelNames = new Set<string>(data.data.suggestedLabels.map((l: SuggestedLabel) => l.name as string))
|
||||||
setSelectedLabels(allLabelNames)
|
setSelectedLabels(allLabelNames)
|
||||||
} else {
|
} else {
|
||||||
// No suggestions is not an error - just close the dialog
|
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
|
toast.info(data.message)
|
||||||
|
} else {
|
||||||
|
toast.info(t('ai.autoLabels.noSuggestions') || 'Pas assez de notes pour générer des labels (minimum 15)')
|
||||||
}
|
}
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
@@ -136,8 +134,10 @@ export function AutoLabelSuggestionDialog({
|
|||||||
<DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle>
|
<DialogTitle className="sr-only">{t('ai.autoLabels.analyzing')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<div className="w-16 h-16 rounded-full border border-dashed border-memento-blue/20 flex items-center justify-center mb-4">
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
<Loader2 className="h-6 w-6 animate-spin text-memento-blue" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||||
{t('ai.autoLabels.analyzing')}
|
{t('ai.autoLabels.analyzing')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +155,7 @@ export function AutoLabelSuggestionDialog({
|
|||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
<Sparkles className="h-5 w-5 text-memento-blue" />
|
||||||
{t('ai.autoLabels.title')}
|
{t('ai.autoLabels.title')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -166,60 +166,73 @@ export function AutoLabelSuggestionDialog({
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 py-4">
|
<div className="space-y-2 py-4">
|
||||||
{suggestions.suggestedLabels.map((label) => (
|
{suggestions.suggestedLabels.map((label) => {
|
||||||
<div
|
const isSelected = selectedLabels.has(label.name)
|
||||||
key={label.name}
|
return (
|
||||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/50 cursor-pointer"
|
<div
|
||||||
onClick={() => toggleLabelSelection(label.name)}
|
key={label.name}
|
||||||
>
|
className={cn(
|
||||||
<Checkbox
|
"flex items-start gap-3 p-3 rounded-xl border cursor-pointer transition-all",
|
||||||
checked={selectedLabels.has(label.name)}
|
isSelected
|
||||||
onCheckedChange={() => toggleLabelSelection(label.name)}
|
? "bg-memento-blue/5 border-memento-blue/30 hover:bg-memento-blue/10"
|
||||||
aria-label={`Select label: ${label.name}`}
|
: "border-border hover:bg-muted/50"
|
||||||
/>
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
onClick={() => toggleLabelSelection(label.name)}
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Tag className="h-4 w-4 text-muted-foreground" />
|
<div className={cn(
|
||||||
<span className="font-medium">{label.name}</span>
|
"w-5 h-5 rounded-full border-2 flex items-center justify-center mt-0.5 transition-all shrink-0",
|
||||||
|
isSelected
|
||||||
|
? "bg-memento-blue border-memento-blue"
|
||||||
|
: "border-border"
|
||||||
|
)}>
|
||||||
|
{isSelected && <CheckCircle2 className="h-3.5 w-3.5 text-white" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{t('ai.autoLabels.notesCount', { count: label.count })}
|
<Tag className="h-3.5 w-3.5 text-memento-blue/60" />
|
||||||
</span>
|
<span className="font-medium text-sm">{label.name}</span>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
|
<Sparkles className="h-3 w-3 text-memento-blue/40" />
|
||||||
{Math.round(label.confidence * 100)}% {t('notebook.confidence')}
|
</div>
|
||||||
</span>
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{t('ai.autoLabels.notesCount', { count: label.count })}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-memento-blue/10 text-memento-blue font-bold">
|
||||||
|
{Math.round(label.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2">
|
||||||
<Button
|
<button
|
||||||
variant="outline"
|
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
|
className="flex-1 py-3 border border-border rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-muted transition-all"
|
||||||
>
|
>
|
||||||
{t('general.cancel')}
|
{t('general.cancel')}
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<button
|
||||||
onClick={handleCreateLabels}
|
onClick={handleCreateLabels}
|
||||||
disabled={selectedLabels.size === 0 || creating}
|
disabled={selectedLabels.size === 0 || creating}
|
||||||
|
className="flex-1 py-3 bg-memento-blue text-white rounded-xl text-[10px] font-bold uppercase tracking-widest hover:opacity-90 transition-all shadow-lg shadow-memento-blue/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<>
|
<span className="flex items-center justify-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
{t('ai.autoLabels.creating')}
|
{t('ai.autoLabels.creating')}
|
||||||
</>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<span className="flex items-center justify-center gap-2">
|
||||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
{t('ai.autoLabels.create')}
|
{t('ai.autoLabels.create')}
|
||||||
</>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
GitMerge, PlusCircle, Eye, Code, Languages,
|
GitMerge, PlusCircle, Eye, Code, Languages,
|
||||||
Presentation, PenTool, ExternalLink, ImagePlus,
|
Presentation, PenTool, ExternalLink, ImagePlus,
|
||||||
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle,
|
ChevronRight, MessageSquare, History, Scissors, Zap, Layout, ArrowRightLeft, Copy, CheckCircle,
|
||||||
|
Tag as TagIcon, RefreshCw,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
||||||
@@ -47,6 +48,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { getNotebookIcon } from '@/lib/notebook-icon'
|
import { getNotebookIcon } from '@/lib/notebook-icon'
|
||||||
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
import { HierarchicalNotebookSelector } from '@/components/hierarchical-notebook-selector'
|
||||||
|
import { AutoLabelSuggestionDialog } from '@/components/auto-label-suggestion-dialog'
|
||||||
import { scrapePageText } from '@/app/actions/scrape'
|
import { scrapePageText } from '@/app/actions/scrape'
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -119,6 +121,10 @@ interface ContextualAIChatProps {
|
|||||||
diagramInsertFormat?: 'markdown' | 'html'
|
diagramInsertFormat?: 'markdown' | 'html'
|
||||||
/** Called to trigger AI title generation for the note */
|
/** Called to trigger AI title generation for the note */
|
||||||
onGenerateTitle?: () => void
|
onGenerateTitle?: () => void
|
||||||
|
/** Notebook ID for label regeneration */
|
||||||
|
notebookId?: string
|
||||||
|
/** Notebook name for display */
|
||||||
|
notebookName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyPreviewButton({ text }: { text: string }) {
|
function CopyPreviewButton({ text }: { text: string }) {
|
||||||
@@ -170,6 +176,8 @@ export function ContextualAIChat({
|
|||||||
className,
|
className,
|
||||||
diagramInsertFormat = 'markdown',
|
diagramInsertFormat = 'markdown',
|
||||||
onGenerateTitle,
|
onGenerateTitle,
|
||||||
|
notebookId,
|
||||||
|
notebookName,
|
||||||
}: ContextualAIChatProps) {
|
}: ContextualAIChatProps) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const webSearchAvailable = useWebSearchAvailable()
|
const webSearchAvailable = useWebSearchAvailable()
|
||||||
@@ -209,6 +217,10 @@ export function ContextualAIChat({
|
|||||||
// hoveredMsgId: which chat message shows inject actions
|
// hoveredMsgId: which chat message shows inject actions
|
||||||
const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null)
|
const [hoveredMsgId, setHoveredMsgId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Label regeneration state
|
||||||
|
const [regenerateLabelsLoading, setRegenerateLabelsLoading] = useState(false)
|
||||||
|
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
const transport = useRef(new DefaultChatTransport({ api: '/api/chat' })).current
|
||||||
@@ -513,6 +525,14 @@ export function ContextualAIChat({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRegenerateLabels = () => {
|
||||||
|
if (!notebookId) {
|
||||||
|
mToast.error(t('ai.autoLabels.noNotebook') || 'Aucun carnet sélectionné')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAutoLabelOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
@@ -522,7 +542,7 @@ export function ContextualAIChat({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<aside className={cn(
|
<aside className={cn(
|
||||||
'border-l border-border bg-background flex flex-col flex-shrink-0 z-10 transition-all duration-300 shadow-2xl',
|
'border-l border-border bg-memento-paper dark:bg-background flex flex-col flex-shrink-0 z-10 transition-all duration-300 shadow-2xl',
|
||||||
expanded
|
expanded
|
||||||
? 'fixed right-0 top-0 h-screen w-[640px] z-[200]'
|
? 'fixed right-0 top-0 h-screen w-[640px] z-[200]'
|
||||||
: 'h-full w-[360px]',
|
: 'h-full w-[360px]',
|
||||||
@@ -585,7 +605,7 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||||
{actionPreview && (
|
{actionPreview && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
<div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
|
<div className="px-6 py-4 border-b border-border flex items-center justify-between shrink-0">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">{actionPreview.label}</p>
|
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">{actionPreview.label}</p>
|
||||||
<button onClick={handleDiscardPreview} className="text-foreground/40 hover:text-foreground"><X size={18} /></button>
|
<button onClick={handleDiscardPreview} className="text-foreground/40 hover:text-foreground"><X size={18} /></button>
|
||||||
@@ -604,7 +624,7 @@ export function ContextualAIChat({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{resourcePreview && (
|
{resourcePreview && (
|
||||||
<div className="absolute inset-0 z-20 flex flex-col bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
<div className="absolute inset-0 z-20 flex flex-col bg-memento-paper/95 dark:bg-background/95 backdrop-blur-md animate-in fade-in slide-in-from-top-4 duration-300">
|
||||||
<div className="px-6 py-4 border-b border-border/40 flex items-center justify-between shrink-0">
|
<div className="px-6 py-4 border-b border-border/40 flex items-center justify-between shrink-0">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
|
<p className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
|
||||||
{resourcePreview.source === 'chat' ? 'Injecter depuis Discussion' : 'Aperçu IA'}
|
{resourcePreview.source === 'chat' ? 'Injecter depuis Discussion' : 'Aperçu IA'}
|
||||||
@@ -770,6 +790,28 @@ export function ContextualAIChat({
|
|||||||
exit={{ opacity: 0, x: -20 }}
|
exit={{ opacity: 0, x: -20 }}
|
||||||
className="flex flex-col flex-1 overflow-y-auto p-6 space-y-10 custom-scrollbar"
|
className="flex flex-col flex-1 overflow-y-auto p-6 space-y-10 custom-scrollbar"
|
||||||
>
|
>
|
||||||
|
{notebookId && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
<h4 className="text-[9px] uppercase tracking-[0.3em] font-bold text-foreground/40 whitespace-nowrap">{t('ai.organization') || 'Organisation'}</h4>
|
||||||
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRegenerateLabels}
|
||||||
|
className="w-full flex items-center gap-3 p-4 bg-card border border-border rounded-xl transition-all hover:border-memento-blue/30 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="p-2 bg-card rounded-lg text-memento-blue shrink-0"><TagIcon size={18} /></div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<h5 className="text-[10px] font-bold text-foreground">{t('ai.autoLabels.regenerate') || 'Labels IA'}</h5>
|
||||||
|
<p className="text-[8px] text-foreground/40 uppercase tracking-tight">{notebookName || ''}</p>
|
||||||
|
</div>
|
||||||
|
<RefreshCw size={14} className="text-memento-blue shrink-0" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-px flex-1 bg-border/40" />
|
<div className="h-px flex-1 bg-border/40" />
|
||||||
@@ -1101,6 +1143,17 @@ export function ContextualAIChat({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
{autoLabelOpen && notebookId && (
|
||||||
|
<AutoLabelSuggestionDialog
|
||||||
|
open={autoLabelOpen}
|
||||||
|
onOpenChange={setAutoLabelOpen}
|
||||||
|
notebookId={notebookId}
|
||||||
|
onLabelsCreated={() => {
|
||||||
|
mToast.success(t('ai.autoLabels.created', { count: 0 }) || 'Labels créés')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TagSuggestion } from '@/lib/ai/types';
|
import { TagSuggestion } from '@/lib/ai/types';
|
||||||
import { Loader2, Sparkles, X, CheckCircle, Plus } from 'lucide-react';
|
import { Sparkles, X, CheckCircle, Plus } from 'lucide-react';
|
||||||
import { cn, getHashColor } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { LABEL_COLORS } from '@/lib/types';
|
|
||||||
import { useLanguage } from '@/lib/i18n';
|
import { useLanguage } from '@/lib/i18n';
|
||||||
|
|
||||||
interface GhostTagsProps {
|
interface GhostTagsProps {
|
||||||
suggestions: TagSuggestion[];
|
suggestions: TagSuggestion[];
|
||||||
addedTags: string[]; // Nouveauté : tags déjà présents sur la note
|
addedTags: string[];
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
onSelectTag: (tag: string) => void;
|
onSelectTag: (tag: string) => void;
|
||||||
onDismissTag: (tag: string) => void;
|
onDismissTag: (tag: string) => void;
|
||||||
@@ -17,85 +16,78 @@ interface GhostTagsProps {
|
|||||||
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
|
export function GhostTags({ suggestions, addedTags, isAnalyzing, onSelectTag, onDismissTag, className }: GhostTagsProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
|
||||||
// On filtre pour l'affichage conditionnel global, mais on garde les tags ajoutés pour l'affichage visuel "validé"
|
|
||||||
const visibleSuggestions = suggestions;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
|
<div className={cn("flex flex-wrap items-center gap-2 mt-2 min-h-[24px] transition-all duration-500", className)}>
|
||||||
|
|
||||||
{isAnalyzing && (
|
{isAnalyzing && (
|
||||||
<div className="flex items-center text-purple-500 animate-pulse" title={t('ai.analyzing')}>
|
<div className="flex items-center gap-1.5 text-memento-blue animate-pulse">
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider">{t('ai.analyzing')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show message when no labels suggested */}
|
{!isAnalyzing && suggestions.length === 0 && (
|
||||||
{!isAnalyzing && visibleSuggestions.length === 0 && (
|
<div className="text-[10px] text-muted-foreground italic">
|
||||||
<div className="text-xs text-gray-500 italic">
|
|
||||||
{t('ai.autoLabels.typeForSuggestions')}
|
{t('ai.autoLabels.typeForSuggestions')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isAnalyzing && visibleSuggestions.map((suggestion) => {
|
{!isAnalyzing && suggestions.map((suggestion) => {
|
||||||
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase());
|
const isAdded = addedTags.some(t => t.toLowerCase() === suggestion.tag.toLowerCase())
|
||||||
const colorName = getHashColor(suggestion.tag);
|
const isNewLabel = suggestion.isNewLabel
|
||||||
const colorClasses = LABEL_COLORS[colorName];
|
|
||||||
const isNewLabel = suggestion.isNewLabel;
|
|
||||||
|
|
||||||
if (isAdded) {
|
if (isAdded) {
|
||||||
// Tag déjà ajouté : on l'affiche en mode "confirmé" statique pour ne pas perdre le focus
|
return (
|
||||||
return (
|
<div
|
||||||
<div key={suggestion.tag} className={cn("flex items-center px-3 py-1 text-xs font-medium border rounded-full opacity-50 cursor-default", colorClasses.bg, colorClasses.text, colorClasses.border)}>
|
key={suggestion.tag}
|
||||||
<CheckCircle className="w-3 h-3 mr-1.5" />
|
className="flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border bg-memento-blue/5 border-memento-blue/20 text-memento-blue opacity-50 cursor-default"
|
||||||
{suggestion.tag}
|
>
|
||||||
</div>
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||||||
)
|
{suggestion.tag}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={suggestion.tag}
|
key={suggestion.tag}
|
||||||
className={cn(
|
className="group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100 border-memento-blue/20 bg-memento-blue/5"
|
||||||
"group flex items-center border border-dashed rounded-full transition-all cursor-pointer animate-in fade-in zoom-in duration-300 opacity-80 hover:opacity-100",
|
|
||||||
colorClasses.bg,
|
|
||||||
colorClasses.border
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* Zone de validation (Clic principal) */}
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.preventDefault()
|
||||||
e.preventDefault();
|
e.stopPropagation()
|
||||||
e.stopPropagation();
|
onSelectTag(suggestion.tag)
|
||||||
onSelectTag(suggestion.tag);
|
}}
|
||||||
}}
|
className="flex items-center px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-memento-blue"
|
||||||
className={cn("flex items-center px-3 py-1 text-xs font-medium", colorClasses.text)}
|
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
|
||||||
title={isNewLabel ? t('ai.autoLabels.createNewLabel') : t('ai.clickToAddTag')}
|
>
|
||||||
>
|
{isNewLabel ? (
|
||||||
{isNewLabel && <Plus className="w-3 h-3 mr-1" />}
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
{!isNewLabel && <Sparkles className="w-3 h-3 mr-1.5 opacity-50" />}
|
) : (
|
||||||
{suggestion.tag}
|
<Sparkles className="w-3 h-3 mr-1.5 opacity-60" />
|
||||||
{isNewLabel && <span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>}
|
)}
|
||||||
</button>
|
{suggestion.tag}
|
||||||
|
{isNewLabel && (
|
||||||
{/* Zone de refus (Croix) */}
|
<span className="ml-1 opacity-60">{t('ai.autoLabels.new')}</span>
|
||||||
<button
|
)}
|
||||||
type="button"
|
</button>
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onDismissTag(suggestion.tag);
|
|
||||||
}}
|
|
||||||
className={cn("pr-2 pl-1 hover:text-red-500 transition-colors", colorClasses.text)}
|
|
||||||
title={t('ai.ignoreSuggestion')}
|
|
||||||
>
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onDismissTag(suggestion.tag)
|
||||||
|
}}
|
||||||
|
className="pr-2 pl-1 text-memento-blue/60 hover:text-red-500 transition-colors"
|
||||||
|
title={t('ai.ignoreSuggestion')}
|
||||||
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function HierarchicalNotebookSelector({
|
|||||||
if (!searchQuery) setIsOpen(false)
|
if (!searchQuery) setIsOpen(false)
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
|
||||||
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold dark:bg-blueprint/10' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`}
|
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold dark:bg-blueprint/10' : 'hover:bg-muted dark:hover:bg-white/5 text-ink'}`}
|
||||||
>
|
>
|
||||||
<div className="w-4 flex items-center justify-center">
|
<div className="w-4 flex items-center justify-center">
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
@@ -124,7 +124,7 @@ export function HierarchicalNotebookSelector({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20 dark:bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20 dark:bg-blueprint/20' : 'bg-muted/50 dark:bg-white/5 group-hover:bg-white/40'}`}>
|
||||||
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ export function HierarchicalNotebookSelector({
|
|||||||
<div
|
<div
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={() => setIsOpen(prev => !prev)}
|
onClick={() => setIsOpen(prev => !prev)}
|
||||||
className={`w-full bg-slate-50 dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
|
className={`w-full bg-card dark:bg-white/5 border border-border/80 rounded-xl outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3 ${size === 'sm' ? 'px-3 py-2 text-xs' : 'px-4 py-3 text-sm'}`}
|
||||||
>
|
>
|
||||||
<Folder size={size === 'sm' ? 14 : 16} className="text-blueprint/60 shrink-0" />
|
<Folder size={size === 'sm' ? 14 : 16} className="text-blueprint/60 shrink-0" />
|
||||||
<div className="flex-1 flex items-center gap-1 min-w-0">
|
<div className="flex-1 flex items-center gap-1 min-w-0">
|
||||||
@@ -192,7 +192,7 @@ export function HierarchicalNotebookSelector({
|
|||||||
style={getDropdownStyle()}
|
style={getDropdownStyle()}
|
||||||
className="bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col"
|
className="bg-card border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="p-3 border-b border-border/40 bg-slate-50/50 dark:bg-white/5">
|
<div className="p-3 border-b border-border/40 bg-card/50 dark:bg-white/5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
|
||||||
<input
|
<input
|
||||||
@@ -210,7 +210,7 @@ export function HierarchicalNotebookSelector({
|
|||||||
{renderTree(null)}
|
{renderTree(null)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2 border-t border-border/40 bg-slate-50/30 dark:bg-white/5 flex justify-between items-center px-4">
|
<div className="p-2 border-t border-border/40 bg-card/30 dark:bg-white/5 flex justify-between items-center px-4">
|
||||||
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">
|
<span className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">
|
||||||
{notebooks.length} notebooks
|
{notebooks.length} notebooks
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { NotesEditorialView } from '@/components/notes-editorial-view'
|
|||||||
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
import { MemoryEchoNotification } from '@/components/memory-echo-notification'
|
||||||
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight } from 'lucide-react'
|
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, Tag as TagIcon, X } from 'lucide-react'
|
||||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||||
import { useRefresh } from '@/lib/use-refresh'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
import { useReminderCheck } from '@/hooks/use-reminder-check'
|
||||||
@@ -88,11 +88,15 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
const [autoLabelOpen, setAutoLabelOpen] = useState(false)
|
||||||
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
const [summaryDialogOpen, setSummaryDialogOpen] = useState(false)
|
||||||
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
|
const [createSubNotebookOpen, setCreateSubNotebookOpen] = useState(false)
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||||||
|
const [isTagsExpanded, setIsTagsExpanded] = useState(false)
|
||||||
|
const [tagSearchQuery, setTagSearchQuery] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldSuggestLabels && suggestNotebookId) {
|
// Auto-trigger disabled — user opens manually from AI panel
|
||||||
setAutoLabelOpen(true)
|
// if (shouldSuggestLabels && suggestNotebookId) {
|
||||||
}
|
// setAutoLabelOpen(true)
|
||||||
|
// }
|
||||||
}, [shouldSuggestLabels, suggestNotebookId])
|
}, [shouldSuggestLabels, suggestNotebookId])
|
||||||
|
|
||||||
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
// Sidebar carnet / inbox: fermer l'éditeur plein écran (comme la ref. architectural-grid)
|
||||||
@@ -278,7 +282,7 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
if (labelFilter.length > 0) {
|
if (labelFilter.length > 0) {
|
||||||
allNotes = allNotes.filter((note: any) =>
|
allNotes = allNotes.filter((note: any) =>
|
||||||
note.labels?.some((label: string) => labelFilter.includes(label))
|
labelFilter.every((label: string) => note.labels?.includes(label))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +345,52 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
return trail
|
return trail
|
||||||
}, [currentNotebook, notebooks])
|
}, [currentNotebook, notebooks])
|
||||||
|
|
||||||
|
const availableTags = useMemo(() => {
|
||||||
|
const tagsMap = new Map<string, { id: string; name: string; type?: string }>()
|
||||||
|
const carnetNotes = notes
|
||||||
|
carnetNotes.forEach(note => {
|
||||||
|
;(note.labels || []).forEach(labelName => {
|
||||||
|
if (!tagsMap.has(labelName)) {
|
||||||
|
const labelObj = labels.find((l: any) => l.name === labelName)
|
||||||
|
tagsMap.set(labelName, {
|
||||||
|
id: labelObj?.id || labelName,
|
||||||
|
name: labelName,
|
||||||
|
type: labelObj?.type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return Array.from(tagsMap.values()).sort((a, b) => {
|
||||||
|
if (a.type === 'ai' && b.type !== 'ai') return -1
|
||||||
|
if (a.type !== 'ai' && b.type === 'ai') return 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
})
|
||||||
|
}, [notes, labels])
|
||||||
|
|
||||||
|
const visibleTags = useMemo(() => {
|
||||||
|
let filtered = availableTags
|
||||||
|
if (tagSearchQuery) {
|
||||||
|
filtered = availableTags.filter(t =>
|
||||||
|
t.name.toLowerCase().includes(tagSearchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
} else if (!isTagsExpanded) {
|
||||||
|
filtered = availableTags.slice(0, 10)
|
||||||
|
selectedTagIds.forEach(id => {
|
||||||
|
if (!filtered.find(t => t.id === id)) {
|
||||||
|
const tag = availableTags.find(t => t.id === id)
|
||||||
|
if (tag) filtered.push(tag)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds])
|
||||||
|
|
||||||
|
const toggleTag = useCallback((tagId: string) => {
|
||||||
|
setSelectedTagIds(prev =>
|
||||||
|
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setControls({
|
setControls({
|
||||||
@@ -351,12 +401,22 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
|
|
||||||
// Apply sort order to notes
|
// Apply sort order to notes
|
||||||
const sortedNotes = useMemo(() => {
|
const sortedNotes = useMemo(() => {
|
||||||
const sorted = [...notes]
|
let sorted = [...notes]
|
||||||
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
if (sortOrder === 'newest') sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
if (sortOrder === 'oldest') sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
|
if (sortOrder === 'alpha') sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
|
||||||
|
|
||||||
|
if (selectedTagIds.length > 0) {
|
||||||
|
const selectedNames = selectedTagIds
|
||||||
|
.map(id => availableTags.find(t => t.id === id)?.name)
|
||||||
|
.filter(Boolean) as string[]
|
||||||
|
sorted = sorted.filter(note =>
|
||||||
|
selectedNames.every(name => (note.labels || []).includes(name))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return sorted
|
return sorted
|
||||||
}, [notes, sortOrder])
|
}, [notes, sortOrder, selectedTagIds, availableTags])
|
||||||
|
|
||||||
const sortedPinnedNotes = useMemo(() => {
|
const sortedPinnedNotes = useMemo(() => {
|
||||||
return sortedNotes.filter(n => n.isPinned)
|
return sortedNotes.filter(n => n.isPinned)
|
||||||
@@ -404,21 +464,20 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
fullPage
|
fullPage
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper flex flex-col">
|
<div className="flex-1 overflow-y-auto min-h-0 bg-memento-paper dark:bg-background flex flex-col">
|
||||||
<div
|
<div
|
||||||
className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 backdrop-blur-md z-30"
|
className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-memento-paper/90 dark:bg-background/90 backdrop-blur-md z-30"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
{currentNotebook && notebookPath.length > 0 && (
|
{currentNotebook && notebookPath.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2"
|
className="flex items-center gap-2 text-[12px] uppercase tracking-[.2em] font-bold mb-2 text-ink/60"
|
||||||
style={{ color: 'var(--color-ink)', opacity: 1 }}
|
|
||||||
>
|
>
|
||||||
{notebookPath.map((nb: any, i: number) => (
|
{notebookPath.map((nb: any, i: number) => (
|
||||||
<React.Fragment key={nb.id}>
|
<React.Fragment key={nb.id}>
|
||||||
{i > 0 && <ChevronRight size={10} className="shrink-0" style={{ color: 'var(--color-concrete)' }} />}
|
{i > 0 && <ChevronRight size={10} className="shrink-0 text-concrete" />}
|
||||||
<span style={{ color: i === notebookPath.length - 1 ? 'var(--color-ink)' : 'var(--color-concrete)' }}>
|
<span className={i === notebookPath.length - 1 ? 'text-ink' : 'text-concrete'}>
|
||||||
{nb.name}
|
{nb.name}
|
||||||
</span>
|
</span>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -557,6 +616,84 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{availableTags.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
|
<TagIcon size={12} />
|
||||||
|
<span>{t('labels.filterByTags') || 'Filter by Tags'}</span>
|
||||||
|
{selectedTagIds.length > 0 && (
|
||||||
|
<span className="bg-memento-blue/10 text-memento-blue px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
||||||
|
{selectedTagIds.length} active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{availableTags.length > 10 && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('labels.searchTags') || 'Search tags...'}
|
||||||
|
className="bg-transparent border-b border-foreground/10 text-[10px] outline-none focus:border-memento-blue/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-muted-foreground/40"
|
||||||
|
value={tagSearchQuery}
|
||||||
|
onChange={e => setTagSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{visibleTags.map(tag => {
|
||||||
|
const isActive = selectedTagIds.includes(tag.id)
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2',
|
||||||
|
isActive
|
||||||
|
? 'bg-foreground text-background border-foreground shadow-sm'
|
||||||
|
: 'bg-card/40 border-border text-muted-foreground hover:border-foreground/30 hover:bg-card/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag.type === 'ai' && (
|
||||||
|
<Sparkles
|
||||||
|
size={10}
|
||||||
|
className={isActive ? 'text-memento-blue' : 'text-memento-blue/60'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tag.name}
|
||||||
|
{isActive && <X size={10} />}
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{availableTags.length > 10 && !tagSearchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
|
||||||
|
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground/60 hover:text-foreground transition-colors border border-dashed border-border rounded-full"
|
||||||
|
>
|
||||||
|
{isTagsExpanded
|
||||||
|
? (t('labels.showLess') || 'Show less')
|
||||||
|
: `+ ${availableTags.length - 10} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedTagIds.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedTagIds([])}
|
||||||
|
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-red-500 hover:underline ml-auto"
|
||||||
|
>
|
||||||
|
{t('labels.clearAll') || 'Clear all'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-12 flex-1 pb-20">
|
<div className="px-12 flex-1 pb-20">
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { X, Sparkles } from 'lucide-react'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { LABEL_COLORS } from '@/lib/types'
|
import { X, Sparkles } from 'lucide-react'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
|
||||||
|
|
||||||
interface LabelBadgeProps {
|
interface LabelBadgeProps {
|
||||||
label: string
|
label: string
|
||||||
type?: 'ai' | 'user' // Optional: if provided, applies AI vs User styling
|
type?: 'ai' | 'user'
|
||||||
onRemove?: () => void
|
onRemove?: () => void
|
||||||
variant?: 'default' | 'filter' | 'clickable'
|
variant?: 'default' | 'filter' | 'clickable'
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
@@ -25,46 +22,54 @@ export function LabelBadge({
|
|||||||
isSelected = false,
|
isSelected = false,
|
||||||
isDisabled = false,
|
isDisabled = false,
|
||||||
}: LabelBadgeProps) {
|
}: LabelBadgeProps) {
|
||||||
const { getLabelColor } = useNotebooks()
|
|
||||||
const colorName = getLabelColor(label)
|
|
||||||
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
|
|
||||||
|
|
||||||
// AI labels get special Blueprint styling with Sparkles icon
|
|
||||||
const isAI = type === 'ai'
|
const isAI = type === 'ai'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'text-xs border gap-1 transition-all',
|
|
||||||
isAI
|
|
||||||
? 'bg-blue-100/70 border-blue-200/50 text-sky-700 dark:bg-sky-900/30 dark:border-sky-700/50 dark:text-sky-300 hover:bg-blue-200/70'
|
|
||||||
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`,
|
|
||||||
variant === 'filter' && 'cursor-pointer hover:opacity-80',
|
|
||||||
variant === 'clickable' && 'cursor-pointer',
|
|
||||||
isDisabled && 'opacity-50',
|
|
||||||
isSelected && 'ring-2 ring-primary'
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider transition-all',
|
||||||
|
variant === 'filter' && !isSelected && 'cursor-pointer',
|
||||||
|
variant === 'clickable' && 'cursor-pointer',
|
||||||
|
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
isSelected
|
||||||
|
? 'bg-foreground text-background border-foreground shadow-sm'
|
||||||
|
: isAI
|
||||||
|
? 'bg-[#75B2D6]/10 border-[#75B2D6]/25 text-[#75B2D6]'
|
||||||
|
: 'bg-[#8D8D8D]/10 border-[#8D8D8D]/25 text-[#8D8D8D]',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{isAI && <Sparkles className="h-3 w-3 text-[#75B2D6]" />}
|
{isAI && (
|
||||||
|
<Sparkles size={8} className="text-[#75B2D6]/70" />
|
||||||
|
)}
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
{onRemove && (
|
{onRemove && (
|
||||||
<button
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onRemove()
|
onRemove()
|
||||||
}}
|
}}
|
||||||
className="hover:text-red-600"
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemove()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="hover:text-red-500 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X size={8} />
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isAI && (
|
|
||||||
<span className="relative flex h-1.5 w-1.5 ml-1">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#75B2D6] opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#75B2D6]"></span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Badge>
|
{isAI && !isSelected && (
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#75B2D6] opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#75B2D6]" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from './ui/dialog'
|
} from './ui/dialog'
|
||||||
import { Settings, Plus, Palette, Trash2, Tag } from 'lucide-react'
|
import { Settings, Plus, Palette, Trash2, Sparkles } from 'lucide-react'
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -19,7 +19,6 @@ import { useLanguage } from '@/lib/i18n'
|
|||||||
import { useRefresh } from '@/lib/use-refresh'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
|
|
||||||
export interface LabelManagementDialogProps {
|
export interface LabelManagementDialogProps {
|
||||||
/** Mode contrôlé (ex. ouverture depuis la liste des carnets) */
|
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
}
|
}
|
||||||
@@ -77,9 +76,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
className="max-w-md"
|
className="max-w-md"
|
||||||
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
|
dir={language === 'fa' || language === 'ar' ? 'rtl' : 'ltr'}
|
||||||
onInteractOutside={(event) => {
|
onInteractOutside={(event) => {
|
||||||
// Prevent dialog from closing when interacting with Sonner toasts
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
const isSonnerElement =
|
const isSonnerElement =
|
||||||
target.closest('[data-sonner-toast]') ||
|
target.closest('[data-sonner-toast]') ||
|
||||||
target.closest('[data-sonner-toaster]') ||
|
target.closest('[data-sonner-toaster]') ||
|
||||||
@@ -88,12 +85,10 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
target.closest('[data-description]') ||
|
target.closest('[data-description]') ||
|
||||||
target.closest('[data-title]') ||
|
target.closest('[data-title]') ||
|
||||||
target.closest('[data-button]');
|
target.closest('[data-button]');
|
||||||
|
|
||||||
if (isSonnerElement) {
|
if (isSonnerElement) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -108,7 +103,6 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
{/* Add new label */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('labels.newLabelPlaceholder')}
|
placeholder={t('labels.newLabelPlaceholder')}
|
||||||
@@ -126,7 +120,6 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List labels */}
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
|
<p className="text-sm text-muted-foreground">{t('labels.loading')}</p>
|
||||||
@@ -136,14 +129,21 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
labels.map((label) => {
|
labels.map((label) => {
|
||||||
const colorClasses = LABEL_COLORS[label.color]
|
const colorClasses = LABEL_COLORS[label.color]
|
||||||
const isEditing = editingColorId === label.id
|
const isEditing = editingColorId === label.id
|
||||||
|
const isAI = label.type === 'ai'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
|
<div key={label.id} className="flex items-center justify-between p-2 rounded-md hover:bg-accent/50 group">
|
||||||
<div className="flex items-center gap-3 flex-1 relative">
|
<div className="flex items-center gap-3 flex-1 relative">
|
||||||
<Tag className={cn("h-4 w-4", colorClasses.text)} />
|
{isAI ? (
|
||||||
|
<Sparkles className={cn("h-4 w-4", "text-memento-blue")} />
|
||||||
|
) : (
|
||||||
|
<div className={cn("h-3 w-3 rounded-full", colorClasses.bg)} />
|
||||||
|
)}
|
||||||
<span className="font-medium text-sm">{label.name}</span>
|
<span className="font-medium text-sm">{label.name}</span>
|
||||||
|
{isAI && (
|
||||||
|
<span className="text-[8px] px-1.5 py-0.5 rounded-full bg-memento-blue/10 text-memento-blue font-bold uppercase">IA</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Color Picker Popover */}
|
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
|
<div className="absolute z-20 top-8 left-0 bg-popover text-popover-foreground border border-border rounded-lg shadow-xl p-3 animate-in fade-in zoom-in-95 w-48">
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
@@ -155,7 +155,7 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
|
'h-7 w-7 rounded-full border-2 transition-all hover:scale-110',
|
||||||
classes.bg,
|
classes.bg,
|
||||||
label.color === color ? 'border-gray-900 dark:border-gray-100 ring-2 ring-offset-1' : 'border-transparent'
|
label.color === color ? 'border-foreground dark:border-foreground ring-2 ring-offset-1' : 'border-transparent'
|
||||||
)}
|
)}
|
||||||
onClick={() => handleChangeColor(label.id, color)}
|
onClick={() => handleChangeColor(label.id, color)}
|
||||||
title={color}
|
title={color}
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from './ui/dialog'
|
} from './ui/dialog'
|
||||||
import { Badge } from './ui/badge'
|
import { Tag, X, Plus, Palette, AlertCircle, Sparkles } from 'lucide-react'
|
||||||
import { Tag, X, Plus, Palette, AlertCircle } from 'lucide-react'
|
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -34,18 +33,16 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
const [editingColor, setEditingColor] = useState<string | null>(null)
|
const [editingColor, setEditingColor] = useState<string | null>(null)
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
// Sync selected labels with existingLabels prop
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedLabels(existingLabels)
|
setSelectedLabels(existingLabels)
|
||||||
}, [existingLabels])
|
}, [existingLabels])
|
||||||
|
|
||||||
const handleAddLabel = async () => {
|
const handleAddLabel = async () => {
|
||||||
const trimmed = newLabel.trim()
|
const trimmed = newLabel.trim()
|
||||||
setErrorMessage(null) // Clear previous error
|
setErrorMessage(null)
|
||||||
|
|
||||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||||
try {
|
try {
|
||||||
// Get existing label color or use random
|
|
||||||
const existingLabel = labels.find(l => l.name === trimmed)
|
const existingLabel = labels.find(l => l.name === trimmed)
|
||||||
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
||||||
|
|
||||||
@@ -113,9 +110,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-md"
|
className="max-w-md"
|
||||||
onInteractOutside={(event) => {
|
onInteractOutside={(event) => {
|
||||||
// Prevent dialog from closing when interacting with Sonner toasts
|
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
|
|
||||||
const isSonnerElement =
|
const isSonnerElement =
|
||||||
target.closest('[data-sonner-toast]') ||
|
target.closest('[data-sonner-toast]') ||
|
||||||
target.closest('[data-sonner-toaster]') ||
|
target.closest('[data-sonner-toaster]') ||
|
||||||
@@ -124,12 +119,10 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
target.closest('[data-description]') ||
|
target.closest('[data-description]') ||
|
||||||
target.closest('[data-title]') ||
|
target.closest('[data-title]') ||
|
||||||
target.closest('[data-button]');
|
target.closest('[data-button]');
|
||||||
|
|
||||||
if (isSonnerElement) {
|
if (isSonnerElement) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.getAttribute('data-sonner-toaster') !== null) {
|
if (target.getAttribute('data-sonner-toaster') !== null) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -144,7 +137,6 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div className="space-y-4 py-4">
|
||||||
{/* Error message */}
|
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
|
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-900">
|
||||||
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" />
|
<AlertCircle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0" />
|
||||||
@@ -152,14 +144,13 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add new label */}
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('labels.newLabelPlaceholder')}
|
placeholder={t('labels.newLabelPlaceholder')}
|
||||||
value={newLabel}
|
value={newLabel}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setNewLabel(e.target.value)
|
setNewLabel(e.target.value)
|
||||||
setErrorMessage(null) // Clear error when typing
|
setErrorMessage(null)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -173,20 +164,20 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected labels */}
|
|
||||||
{selectedLabels.length > 0 && (
|
{selectedLabels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2">{t('labels.selectedLabels')}</h4>
|
<h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">{t('labels.selectedLabels')}</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedLabels.map((label) => {
|
{selectedLabels.map((label) => {
|
||||||
const labelObj = labels.find(l => l.name === label)
|
const labelObj = labels.find(l => l.name === label)
|
||||||
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
|
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
|
||||||
const isEditing = editingColor === label
|
const isEditing = editingColor === label
|
||||||
|
const isAI = labelObj?.type === 'ai'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label} className="relative">
|
<div key={label} className="relative">
|
||||||
{isEditing && labelObj ? (
|
{isEditing && labelObj ? (
|
||||||
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2">
|
<div className="absolute z-10 top-8 left-0 bg-popover border rounded-lg shadow-lg p-2">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||||
const classes = LABEL_COLORS[color]
|
const classes = LABEL_COLORS[color]
|
||||||
@@ -196,7 +187,7 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
className={cn(
|
className={cn(
|
||||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||||
classes.bg,
|
classes.bg,
|
||||||
labelObj.color === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
|
labelObj.color === color ? 'border-foreground dark:border-foreground' : 'border-border'
|
||||||
)}
|
)}
|
||||||
onClick={() => handleChangeColor(label, color)}
|
onClick={() => handleChangeColor(label, color)}
|
||||||
title={color}
|
title={color}
|
||||||
@@ -206,27 +197,30 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Badge
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs border cursor-pointer pr-1 flex items-center gap-1',
|
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider cursor-pointer',
|
||||||
colorClasses.bg,
|
isAI
|
||||||
colorClasses.text,
|
? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
|
||||||
colorClasses.border
|
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
|
||||||
)}
|
)}
|
||||||
onClick={() => setEditingColor(isEditing ? null : label)}
|
onClick={() => setEditingColor(isEditing ? null : label)}
|
||||||
>
|
>
|
||||||
|
{isAI && <Sparkles className="h-3 w-3" />}
|
||||||
<Palette className="h-3 w-3" />
|
<Palette className="h-3 w-3" />
|
||||||
{label}
|
{label}
|
||||||
<button
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleRemoveLabel(label)
|
handleRemoveLabel(label)
|
||||||
}}
|
}}
|
||||||
className="ml-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full p-0.5"
|
className="ml-1 hover:text-red-500 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</span>
|
||||||
</Badge>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -234,30 +228,30 @@ export function LabelManager({ existingLabels, notebookId, onUpdate }: LabelMana
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Available labels from context */}
|
|
||||||
{!loading && labels.length > 0 && (
|
{!loading && labels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2">{t('labels.allLabels')}</h4>
|
<h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">{t('labels.allLabels')}</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{labels
|
{labels
|
||||||
.filter(label => !selectedLabels.includes(label.name))
|
.filter(label => !selectedLabels.includes(label.name))
|
||||||
.map((label) => {
|
.map((label) => {
|
||||||
const colorClasses = LABEL_COLORS[label.color]
|
const colorClasses = LABEL_COLORS[label.color]
|
||||||
|
const isAI = label.type === 'ai'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<button
|
||||||
key={label.id}
|
key={label.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs border cursor-pointer',
|
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider cursor-pointer transition-all hover:opacity-80',
|
||||||
colorClasses.bg,
|
isAI
|
||||||
colorClasses.text,
|
? 'bg-memento-blue/5 border-memento-blue/20 text-memento-blue'
|
||||||
colorClasses.border,
|
: `${colorClasses.bg} ${colorClasses.text} ${colorClasses.border}`
|
||||||
'hover:opacity-80'
|
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSelectExisting(label.name)}
|
onClick={() => handleSelectExisting(label.name)}
|
||||||
>
|
>
|
||||||
|
{isAI && <Sparkles className="h-3 w-3" />}
|
||||||
{label.name}
|
{label.name}
|
||||||
</Badge>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,30 +26,8 @@ export function NoteContentArea() {
|
|||||||
return data.url
|
return data.url
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.noteType === 'richtext') {
|
// Markdown preview mode
|
||||||
if (fullPage) {
|
if (state.isMarkdown && state.showMarkdownPreview) {
|
||||||
return (
|
|
||||||
<div className="fullpage-editor">
|
|
||||||
<RichTextEditor
|
|
||||||
content={state.content}
|
|
||||||
onChange={(v: string) => actions.setContent(v)}
|
|
||||||
className="min-h-[280px]"
|
|
||||||
onImageUpload={uploadImageFile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<RichTextEditor
|
|
||||||
content={state.content}
|
|
||||||
onChange={actions.setContent}
|
|
||||||
className="min-h-[200px]"
|
|
||||||
onImageUpload={uploadImageFile}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.noteType === 'markdown' && state.showMarkdownPreview) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -68,7 +46,8 @@ export function NoteContentArea() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.noteType === 'markdown' || state.noteType === 'text') {
|
// Markdown edit mode
|
||||||
|
if (state.isMarkdown) {
|
||||||
if (fullPage) {
|
if (fullPage) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -93,12 +72,11 @@ export function NoteContentArea() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog mode
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
dir="auto"
|
dir="auto"
|
||||||
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
|
placeholder={t('notes.takeNoteMarkdown') || t('notes.takeNote')}
|
||||||
value={state.content}
|
value={state.content}
|
||||||
onChange={(e) => actions.setContent(e.target.value)}
|
onChange={(e) => actions.setContent(e.target.value)}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
@@ -118,62 +96,35 @@ export function NoteContentArea() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checklist mode
|
// Richtext mode (default)
|
||||||
if (fullPage) {
|
if (fullPage) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="fullpage-editor">
|
||||||
{state.checkItems.map((item) => (
|
<RichTextEditor
|
||||||
<div key={item.id} className="flex items-start gap-2 group">
|
content={state.content}
|
||||||
<Checkbox
|
onChange={(v: string) => actions.setContent(v)}
|
||||||
checked={item.checked}
|
className="min-h-[280px]"
|
||||||
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
onImageUpload={uploadImageFile}
|
||||||
className="mt-2"
|
/>
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={item.text}
|
|
||||||
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
|
|
||||||
placeholder={t('notes.listItem')}
|
|
||||||
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
|
||||||
/>
|
|
||||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
|
||||||
onClick={() => actions.handleRemoveCheckItem(item.id)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
{t('notes.addItem')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{state.checkItems.map((item) => (
|
<RichTextEditor
|
||||||
<div key={item.id} className="flex items-start gap-2 group">
|
content={state.content}
|
||||||
<Checkbox
|
onChange={actions.setContent}
|
||||||
checked={item.checked}
|
className="min-h-[200px]"
|
||||||
onCheckedChange={() => actions.handleCheckItem(item.id)}
|
onImageUpload={uploadImageFile}
|
||||||
className="mt-2"
|
/>
|
||||||
/>
|
<GhostTags
|
||||||
<Input
|
suggestions={state.filteredSuggestions}
|
||||||
value={item.text}
|
addedTags={state.labels}
|
||||||
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
|
isAnalyzing={state.isAnalyzingSuggestions}
|
||||||
placeholder={t('notes.listItem')}
|
onSelectTag={actions.handleSelectGhostTag}
|
||||||
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
|
onDismissTag={actions.handleDismissGhostTag}
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
|
|
||||||
onClick={() => actions.handleRemoveCheckItem(item.id)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
{t('notes.addItem')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
|
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, NoteSize } from '@/lib/types'
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
|
||||||
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
|
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
|
||||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -48,7 +48,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
}
|
}
|
||||||
}, [session?.user?.id])
|
}, [session?.user?.id])
|
||||||
|
|
||||||
// Core content state
|
|
||||||
const [title, setTitle] = useState(note.title || '')
|
const [title, setTitle] = useState(note.title || '')
|
||||||
const [content, setContent] = useState(note.content)
|
const [content, setContent] = useState(note.content)
|
||||||
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
|
||||||
@@ -60,16 +59,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
const [size, setSize] = useState<NoteSize>(note.size || 'small')
|
const [size, setSize] = useState<NoteSize>(note.size || 'small')
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
|
||||||
const [noteType, setNoteType] = useState<NoteType>(note.type)
|
const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown')
|
||||||
const isMarkdown = noteType === 'markdown'
|
|
||||||
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
|
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
|
||||||
|
|
||||||
// Refs
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const prevNoteRef = useRef(note)
|
const prevNoteRef = useRef(note)
|
||||||
|
|
||||||
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
|
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
|
||||||
setTitle(note.title || '')
|
setTitle(note.title || '')
|
||||||
@@ -80,40 +76,54 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
setLinks(note.links || [])
|
setLinks(note.links || [])
|
||||||
setColor(note.color)
|
setColor(note.color)
|
||||||
setSize(note.size || 'small')
|
setSize(note.size || 'small')
|
||||||
setNoteType(note.type)
|
setIsMarkdown(note.type === 'markdown')
|
||||||
setShowMarkdownPreview(note.type === 'markdown')
|
setShowMarkdownPreview(note.type === 'markdown')
|
||||||
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
|
||||||
}
|
}
|
||||||
prevNoteRef.current = note
|
prevNoteRef.current = note
|
||||||
}, [note])
|
}, [note])
|
||||||
|
|
||||||
// Update context notebookId when note changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContextNotebookId(note.notebookId || null)
|
setContextNotebookId(note.notebookId || null)
|
||||||
}, [note.notebookId, setContextNotebookId])
|
}, [note.notebookId, setContextNotebookId])
|
||||||
|
|
||||||
// Auto-tagging hook
|
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||||
|
const dismissedTagsLoadedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dismissedTagsLoadedRef.current = false
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`dismissed-tags-${note.id}`)
|
||||||
|
if (stored) {
|
||||||
|
setDismissedTags(JSON.parse(stored))
|
||||||
|
dismissedTagsLoadedRef.current = true
|
||||||
|
} else {
|
||||||
|
setDismissedTags([])
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
setDismissedTags([])
|
||||||
|
}
|
||||||
|
}, [note.id])
|
||||||
|
|
||||||
|
const autoTaggingEnabled = autoLabelingEnabled && dismissedTags.length < 3
|
||||||
|
|
||||||
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
|
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
|
||||||
content: noteType !== 'checklist' ? content : '',
|
content: content,
|
||||||
notebookId: note.notebookId,
|
notebookId: note.notebookId,
|
||||||
enabled: noteType !== 'checklist' && autoLabelingEnabled
|
enabled: autoTaggingEnabled
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reminder state
|
|
||||||
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
const [showReminderDialog, setShowReminderDialog] = useState(false)
|
||||||
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
const [currentReminder, setCurrentReminder] = useState<Date | null>(
|
||||||
note.reminder ? new Date(note.reminder as unknown as string) : null
|
note.reminder ? new Date(note.reminder as unknown as string) : null
|
||||||
)
|
)
|
||||||
|
|
||||||
// Link state
|
|
||||||
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
const [showLinkDialog, setShowLinkDialog] = useState(false)
|
||||||
const [linkUrl, setLinkUrl] = useState('')
|
const [linkUrl, setLinkUrl] = useState('')
|
||||||
|
|
||||||
// Title suggestions state
|
|
||||||
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
|
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
|
||||||
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
|
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
|
||||||
|
|
||||||
// Reformulation state
|
|
||||||
const [isReformulating, setIsReformulating] = useState(false)
|
const [isReformulating, setIsReformulating] = useState(false)
|
||||||
const [reformulationModal, setReformulationModal] = useState<{
|
const [reformulationModal, setReformulationModal] = useState<{
|
||||||
originalText: string
|
originalText: string
|
||||||
@@ -121,38 +131,28 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
option: string
|
option: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
// AI processing state
|
|
||||||
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
const [isProcessingAI, setIsProcessingAI] = useState(false)
|
||||||
const [aiOpen, setAiOpen] = useState(false)
|
const [aiOpen, setAiOpen] = useState(false)
|
||||||
const [infoOpen, setInfoOpen] = useState(false)
|
const [infoOpen, setInfoOpen] = useState(false)
|
||||||
const [isDirty, setIsDirty] = useState(false)
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
|
||||||
// fullPage — auto title suggestions
|
|
||||||
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
|
||||||
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
|
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
|
||||||
content,
|
content,
|
||||||
enabled: fullPage && !title && !dismissedTitleSuggestions,
|
enabled: fullPage && !title && !dismissedTitleSuggestions,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wire autoTitleSuggestions into state so NoteTitleBlock can display them
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoTitleSuggestions.length > 0) {
|
if (autoTitleSuggestions.length > 0) {
|
||||||
setTitleSuggestions(autoTitleSuggestions)
|
setTitleSuggestions(autoTitleSuggestions)
|
||||||
}
|
}
|
||||||
}, [autoTitleSuggestions])
|
}, [autoTitleSuggestions])
|
||||||
|
|
||||||
// Track previous content for copilot action undo
|
|
||||||
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
|
||||||
|
|
||||||
// Memory Echo Connections state
|
|
||||||
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||||
|
|
||||||
// Tags dismissed by the user for this session
|
|
||||||
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
|
||||||
|
|
||||||
// Filter suggestions to exclude dismissed ones
|
|
||||||
// and those already present on the note
|
|
||||||
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
|
||||||
const filteredSuggestions = suggestions.filter(s => {
|
const filteredSuggestions = suggestions.filter(s => {
|
||||||
if (!s || !s.tag) return false
|
if (!s || !s.tag) return false
|
||||||
@@ -185,10 +185,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paste handler: upload clipboard images
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
if (!isMarkdown && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||||
const items = e.clipboardData?.items
|
const items = e.clipboardData?.items
|
||||||
if (!items) return
|
if (!items) return
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
@@ -207,9 +206,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
}
|
}
|
||||||
document.addEventListener('paste', handlePaste, { capture: true })
|
document.addEventListener('paste', handlePaste, { capture: true })
|
||||||
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||||
}, [t, noteType])
|
}, [t, isMarkdown])
|
||||||
|
|
||||||
// Auto-grow textarea as content grows
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -217,10 +215,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
|
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
|
||||||
}, [content])
|
}, [content])
|
||||||
|
|
||||||
// Also auto-grow when switching FROM preview TO edit mode
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showMarkdownPreview) return // we're in preview, textarea not mounted
|
if (showMarkdownPreview) return
|
||||||
// Defer one frame so the textarea is in the DOM
|
|
||||||
const raf = requestAnimationFrame(() => {
|
const raf = requestAnimationFrame(() => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -234,7 +230,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
const handleRemoveImage = (index: number) => {
|
const handleRemoveImage = (index: number) => {
|
||||||
const removedUrl = images[index]
|
const removedUrl = images[index]
|
||||||
setImages(images.filter((_, i) => i !== index))
|
setImages(images.filter((_, i) => i !== index))
|
||||||
// Track removed images for cleanup on save
|
|
||||||
if (removedUrl) {
|
if (removedUrl) {
|
||||||
setRemovedImageUrls(prev => [...prev, removedUrl])
|
setRemovedImageUrls(prev => [...prev, removedUrl])
|
||||||
}
|
}
|
||||||
@@ -267,9 +262,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allImages = useMemo(() => {
|
const allImages = useMemo(() => {
|
||||||
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
const extracted = !isMarkdown ? extractImagesFromHTML(content) : [];
|
||||||
return Array.from(new Set([...images, ...extracted]));
|
return Array.from(new Set([...images, ...extracted]));
|
||||||
}, [images, content, noteType]);
|
}, [images, content, isMarkdown]);
|
||||||
|
|
||||||
const handleGenerateTitles = async () => {
|
const handleGenerateTitles = async () => {
|
||||||
const fullContentForAI = [
|
const fullContentForAI = [
|
||||||
@@ -301,7 +296,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setTitleSuggestions(data.suggestions || [])
|
setTitleSuggestions(data.suggestions || [])
|
||||||
// Auto-apply first title for dialog mode (fullPage shows suggestions UI instead)
|
|
||||||
if (!fullPage && data.suggestions?.[0]?.title) {
|
if (!fullPage && data.suggestions?.[0]?.title) {
|
||||||
setTitle(data.suggestions[0].title)
|
setTitle(data.suggestions[0].title)
|
||||||
setDismissedTitleSuggestions(true)
|
setDismissedTitleSuggestions(true)
|
||||||
@@ -485,7 +479,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
||||||
|
|
||||||
setContent(data.transformedText)
|
setContent(data.transformedText)
|
||||||
setNoteType('markdown')
|
setIsMarkdown(true)
|
||||||
setShowMarkdownPreview(false)
|
setShowMarkdownPreview(false)
|
||||||
|
|
||||||
toast.success(t('ai.transformSuccess'))
|
toast.success(t('ai.transformSuccess'))
|
||||||
@@ -500,13 +494,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
const handleApplyRefactor = () => {
|
const handleApplyRefactor = () => {
|
||||||
if (!reformulationModal) return
|
if (!reformulationModal) return
|
||||||
|
|
||||||
const selectedText = window.getSelection()?.toString()
|
setContent(reformulationModal.reformulatedText)
|
||||||
if (selectedText) {
|
|
||||||
setContent(reformulationModal.reformulatedText)
|
|
||||||
} else {
|
|
||||||
setContent(reformulationModal.reformulatedText)
|
|
||||||
}
|
|
||||||
|
|
||||||
setReformulationModal(null)
|
setReformulationModal(null)
|
||||||
toast.success(t('ai.reformulationApplied'))
|
toast.success(t('ai.reformulationApplied'))
|
||||||
}
|
}
|
||||||
@@ -536,35 +524,27 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
console.log('[SAVE] handleSave called, note.id:', note.id)
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
console.log('[SAVE] Calling updateNote...')
|
|
||||||
const result = await updateNote(note.id, {
|
const result = await updateNote(note.id, {
|
||||||
title: title.trim() || null,
|
title: title.trim() || null,
|
||||||
content: noteType !== 'checklist' ? content : '',
|
content,
|
||||||
checkItems: noteType === 'checklist' ? checkItems : null,
|
checkItems: null,
|
||||||
labels,
|
labels,
|
||||||
images,
|
images,
|
||||||
links,
|
links,
|
||||||
color,
|
color,
|
||||||
reminder: currentReminder,
|
reminder: currentReminder,
|
||||||
isMarkdown: noteType === 'markdown',
|
isMarkdown,
|
||||||
type: noteType,
|
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||||
size,
|
size,
|
||||||
})
|
})
|
||||||
console.log('[SAVE] updateNote succeeded, result title:', result?.title, 'result content len:', result?.content?.length)
|
|
||||||
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
|
|
||||||
// Keep local note ref in sync with saved data so useEffect detects changes correctly
|
|
||||||
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
||||||
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
|
|
||||||
if (removedImageUrls.length > 0) {
|
if (removedImageUrls.length > 0) {
|
||||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||||
}
|
}
|
||||||
await refreshLabels()
|
await refreshLabels()
|
||||||
// Notify parent with the freshly-saved note so it can update its local state immediately
|
|
||||||
onNoteSaved?.(result)
|
onNoteSaved?.(result)
|
||||||
// Invalidate note and notes list cache
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
@@ -607,6 +587,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
|
|
||||||
if (!tagExists) {
|
if (!tagExists) {
|
||||||
setLabels(prev => [...prev, tag])
|
setLabels(prev => [...prev, tag])
|
||||||
|
setIsDirty(true)
|
||||||
|
|
||||||
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
||||||
if (!globalExists) {
|
if (!globalExists) {
|
||||||
@@ -621,11 +602,16 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissGhostTag = (tag: string) => {
|
const handleDismissGhostTag = (tag: string) => {
|
||||||
setDismissedTags(prev => [...prev, tag])
|
setDismissedTags(prev => {
|
||||||
|
const next = [...prev, tag]
|
||||||
|
try { localStorage.setItem(`dismissed-tags-${note.id}`, JSON.stringify(next)) } catch (_) {}
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveLabel = (label: string) => {
|
const handleRemoveLabel = (label: string) => {
|
||||||
setLabels(labels.filter(l => l !== label))
|
setLabels(labels.filter(l => l !== label))
|
||||||
|
setIsDirty(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMakeCopy = async () => {
|
const handleMakeCopy = async () => {
|
||||||
@@ -638,54 +624,42 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
labels: labels,
|
labels: labels,
|
||||||
images: images,
|
images: images,
|
||||||
links: links,
|
links: links,
|
||||||
isMarkdown: noteType === 'markdown',
|
isMarkdown,
|
||||||
type: noteType,
|
type: isMarkdown ? 'markdown' : 'richtext',
|
||||||
size: size,
|
size: size,
|
||||||
})
|
})
|
||||||
toast.success(t('notes.copySuccess'))
|
toast.success(t('notes.copySuccess'))
|
||||||
// Invalidate notes list cache for current notebook
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
// Note: onClose is handled by the composition component
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy note:', error)
|
console.error('Failed to copy note:', error)
|
||||||
toast.error(t('notes.copyFailed'))
|
toast.error(t('notes.copyFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save in place (fullPage) — without closing
|
|
||||||
const handleSaveInPlace = async () => {
|
const handleSaveInPlace = async () => {
|
||||||
console.log('[SAVE] handleSaveInPlace called, note.id:', note.id, 'content length:', content.length, 'title:', title.substring(0, 50))
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
console.log('[SAVE] Calling updateNote with note.id:', note.id, '| content len:', content.length, '| title:', title.substring(0, 30))
|
|
||||||
const updatePayload = {
|
const updatePayload = {
|
||||||
title: title.trim() || null,
|
title: title.trim() || null,
|
||||||
content: noteType !== 'checklist' ? content : '',
|
content,
|
||||||
checkItems: noteType === 'checklist' ? checkItems : null,
|
checkItems: null,
|
||||||
labels,
|
labels,
|
||||||
images,
|
images,
|
||||||
links,
|
links,
|
||||||
color,
|
color,
|
||||||
reminder: currentReminder,
|
reminder: currentReminder,
|
||||||
isMarkdown: noteType === 'markdown',
|
isMarkdown,
|
||||||
type: noteType,
|
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
|
||||||
size,
|
size,
|
||||||
}
|
}
|
||||||
console.log('[SAVE] payload.content:', JSON.stringify(updatePayload.content)?.substring(0, 100))
|
|
||||||
const result = await updateNote(note.id, updatePayload)
|
const result = await updateNote(note.id, updatePayload)
|
||||||
console.log('[SAVE] updateNote succeeded, result.id:', result?.id, '| result.content len:', result?.content?.length, '| result.title:', result?.title)
|
|
||||||
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
|
|
||||||
// Sync local note reference with saved data so prop/state stay aligned after save
|
|
||||||
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
prevNoteRef.current = { ...prevNoteRef.current, ...result }
|
||||||
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
|
|
||||||
if (removedImageUrls.length > 0) {
|
if (removedImageUrls.length > 0) {
|
||||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||||
}
|
}
|
||||||
await refreshLabels()
|
await refreshLabels()
|
||||||
// Notify parent with the freshly-saved note so it can update its local state immediately
|
|
||||||
onNoteSaved?.(result)
|
onNoteSaved?.(result)
|
||||||
// Invalidate note and notes list cache
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
@@ -699,7 +673,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fullPage) return
|
if (!fullPage) return
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
@@ -712,7 +685,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
return () => document.removeEventListener('keydown', handler)
|
return () => document.removeEventListener('keydown', handler)
|
||||||
}, [fullPage, isSaving])
|
}, [fullPage, isSaving])
|
||||||
|
|
||||||
// Build state object
|
|
||||||
const state: NoteEditorState = useMemo(() => ({
|
const state: NoteEditorState = useMemo(() => ({
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
@@ -723,7 +695,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
newLabel,
|
newLabel,
|
||||||
color: color as NoteColor,
|
color: color as NoteColor,
|
||||||
size,
|
size,
|
||||||
noteType,
|
|
||||||
showMarkdownPreview,
|
showMarkdownPreview,
|
||||||
removedImageUrls,
|
removedImageUrls,
|
||||||
isSaving,
|
isSaving,
|
||||||
@@ -750,7 +721,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
allImages,
|
allImages,
|
||||||
colorClasses,
|
colorClasses,
|
||||||
}), [
|
}), [
|
||||||
title, content, checkItems, labels, images, links, newLabel, color, size, noteType,
|
title, content, checkItems, labels, images, links, newLabel, color, size,
|
||||||
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
|
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
|
||||||
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||||
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||||
@@ -758,10 +729,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
|
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
|
||||||
])
|
])
|
||||||
|
|
||||||
// Build actions object — NOT memoized to avoid stale closures.
|
|
||||||
// handleSave / handleSaveInPlace close over content, title, labels, etc.
|
|
||||||
// which change on every keystroke. Memoizing with [] would freeze those
|
|
||||||
// values at the first render, causing the wrong content to be saved.
|
|
||||||
const actions: NoteEditorActions = {
|
const actions: NoteEditorActions = {
|
||||||
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
|
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
|
||||||
setDismissedTitleSuggestions,
|
setDismissedTitleSuggestions,
|
||||||
@@ -782,8 +749,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
setLinks,
|
setLinks,
|
||||||
handleAddLink,
|
handleAddLink,
|
||||||
handleRemoveLink,
|
handleRemoveLink,
|
||||||
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
|
|
||||||
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
|
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
|
||||||
|
setIsMarkdown: (m) => { setIsMarkdown(m); setIsDirty(true) },
|
||||||
setColor: (c) => { setColor(c); setIsDirty(true) },
|
setColor: (c) => { setColor(c); setIsDirty(true) },
|
||||||
setSize: (s) => { setSize(s); setIsDirty(true) },
|
setSize: (s) => { setSize(s); setIsDirty(true) },
|
||||||
setShowReminderDialog,
|
setShowReminderDialog,
|
||||||
@@ -815,7 +782,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
|
|||||||
setPreviousContentForCopilot,
|
setPreviousContentForCopilot,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const value: NoteEditorContextValue = useMemo(() => ({
|
const value: NoteEditorContextValue = useMemo(() => ({
|
||||||
note,
|
note,
|
||||||
readOnly,
|
readOnly,
|
||||||
@@ -841,4 +807,4 @@ export function useNoteEditorContext() {
|
|||||||
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
|
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
|
||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
|
|||||||
} : undefined}
|
} : undefined}
|
||||||
lastActionApplied={state.previousContentForCopilot !== null}
|
lastActionApplied={state.previousContentForCopilot !== null}
|
||||||
notebooks={notebooks}
|
notebooks={notebooks}
|
||||||
|
notebookId={note.notebookId ?? undefined}
|
||||||
|
notebookName={notebooks.find(nb => nb.id === note.notebookId)?.name ?? undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -14,23 +14,30 @@ import { format } from 'date-fns'
|
|||||||
import { ChevronRight } from 'lucide-react'
|
import { ChevronRight } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Note } from '@/lib/types'
|
import { Note } from '@/lib/types'
|
||||||
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
|
import { LabelBadge } from '@/components/label-badge'
|
||||||
|
|
||||||
interface NoteEditorFullPageProps {
|
interface NoteEditorFullPageProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
||||||
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
|
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
|
||||||
|
|
||||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||||
|
|
||||||
|
const getLabelType = (name: string): 'ai' | 'user' => {
|
||||||
|
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
|
||||||
|
return (found as any)?.type === 'ai' ? 'ai' : 'user'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── outer container ── */}
|
{/* ── outer container ── */}
|
||||||
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
|
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
|
||||||
|
|
||||||
{/* ── main scrollable column ── */}
|
{/* ── main scrollable column ── */}
|
||||||
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-zinc-950">
|
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
|
||||||
|
|
||||||
{/* TOOLBAR */}
|
{/* TOOLBAR */}
|
||||||
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
|
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
|
||||||
@@ -51,6 +58,28 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<NoteTitleBlock />
|
<NoteTitleBlock />
|
||||||
|
|
||||||
|
{(state.labels.length > 0 || (state.filteredSuggestions.length > 0)) && (
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
{state.labels.map((label) => (
|
||||||
|
<LabelBadge
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
type={getLabelType(label)}
|
||||||
|
onRemove={() => actions.handleRemoveLabel(label)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<GhostTags
|
||||||
|
suggestions={state.filteredSuggestions}
|
||||||
|
addedTags={state.labels}
|
||||||
|
isAnalyzing={state.isAnalyzingSuggestions}
|
||||||
|
onSelectTag={actions.handleSelectGhostTag}
|
||||||
|
onDismissTag={actions.handleDismissGhostTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero image — show first note image if present */}
|
{/* Hero image — show first note image if present */}
|
||||||
@@ -83,12 +112,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
|
|||||||
onApplyToNote={(nc: string) => {
|
onApplyToNote={(nc: string) => {
|
||||||
actions.setPreviousContentForCopilot(state.content)
|
actions.setPreviousContentForCopilot(state.content)
|
||||||
actions.setContent(nc)
|
actions.setContent(nc)
|
||||||
if (state.noteType === 'markdown') actions.setShowMarkdownPreview(true)
|
if (state.isMarkdown) actions.setShowMarkdownPreview(true)
|
||||||
}}
|
}}
|
||||||
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
|
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
|
||||||
lastActionApplied={state.previousContentForCopilot !== null}
|
lastActionApplied={state.previousContentForCopilot !== null}
|
||||||
notebooks={notebooks}
|
notebooks={notebooks}
|
||||||
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'}
|
notebookId={note.notebookId ?? undefined}
|
||||||
|
notebookName={notebookName ?? undefined}
|
||||||
|
diagramInsertFormat={state.isMarkdown ? 'markdown' : 'html'}
|
||||||
onGenerateTitle={async () => {
|
onGenerateTitle={async () => {
|
||||||
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
const wordCount = plain.split(/\s+/).filter(Boolean).length
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { LabelBadge } from '@/components/label-badge'
|
|||||||
import { GhostTags } from '@/components/ghost-tags'
|
import { GhostTags } from '@/components/ghost-tags'
|
||||||
import { EditorImages } from '@/components/editor-images'
|
import { EditorImages } from '@/components/editor-images'
|
||||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||||
import { NoteTypeSelector } from '@/components/note-type-selector'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -44,32 +43,28 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
|
|
||||||
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
|
||||||
|
|
||||||
// Snapshot for undo — stored in a ref so the toast callback isn't a stale closure
|
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
|
||||||
const undoSnapshotRef = useRef<{ content: string; noteType: string } | null>(null)
|
|
||||||
|
|
||||||
const handleConvertToRichtext = async () => {
|
const handleConvertToRichtext = async () => {
|
||||||
if (isConverting || !state.content.trim()) return
|
if (isConverting || !state.content.trim()) return
|
||||||
setIsConverting(true)
|
setIsConverting(true)
|
||||||
|
|
||||||
// Capture snapshot BEFORE converting
|
const snapshot = { content: state.content, isMarkdown: state.isMarkdown }
|
||||||
const snapshot = { content: state.content, noteType: state.noteType }
|
|
||||||
undoSnapshotRef.current = snapshot
|
undoSnapshotRef.current = snapshot
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let html: string
|
let html: string
|
||||||
if (state.noteType === 'markdown') {
|
if (state.isMarkdown) {
|
||||||
// Proper markdown → HTML via marked (no AI needed)
|
|
||||||
const { marked } = await import('marked')
|
const { marked } = await import('marked')
|
||||||
html = await marked(state.content, { async: false }) as string
|
html = await marked(state.content, { async: false }) as string
|
||||||
} else {
|
} else {
|
||||||
// Plain text → wrap paragraphs in <p> tags
|
|
||||||
html = state.content
|
html = state.content
|
||||||
.split(/\n{2,}/)
|
.split(/\n{2,}/)
|
||||||
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
|
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
|
||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
actions.setContent(html)
|
actions.setContent(html)
|
||||||
actions.setNoteType('richtext')
|
actions.setIsMarkdown(false)
|
||||||
|
|
||||||
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
|
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
@@ -79,7 +74,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
const snap = undoSnapshotRef.current
|
const snap = undoSnapshotRef.current
|
||||||
if (!snap) return
|
if (!snap) return
|
||||||
actions.setContent(snap.content)
|
actions.setContent(snap.content)
|
||||||
actions.setNoteType(snap.noteType as any)
|
if (snap.isMarkdown) actions.setIsMarkdown(true)
|
||||||
undoSnapshotRef.current = null
|
undoSnapshotRef.current = null
|
||||||
toast.info(t('ai.undoApplied') || 'Conversion undone')
|
toast.info(t('ai.undoApplied') || 'Conversion undone')
|
||||||
},
|
},
|
||||||
@@ -94,8 +89,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
|
|
||||||
if (mode === 'fullPage') {
|
if (mode === 'fullPage') {
|
||||||
return (
|
return (
|
||||||
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
|
||||||
{/* Left: back */}
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
|
||||||
@@ -104,9 +98,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
<span className="text-sm font-medium">Back to collection</span>
|
<span className="text-sm font-medium">Back to collection</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Right: status + type + AI + Info */}
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Save status */}
|
|
||||||
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
|
||||||
{state.isSaving
|
{state.isSaving
|
||||||
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving…</span></>
|
||||||
@@ -115,15 +107,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
|
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Note type */}
|
{state.isMarkdown && !readOnly && (
|
||||||
<NoteTypeSelector
|
|
||||||
value={state.noteType}
|
|
||||||
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Preview toggle — icon only */}
|
|
||||||
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
|
|
||||||
<button
|
<button
|
||||||
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
|
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
|
||||||
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
|
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
|
||||||
@@ -139,8 +123,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Convert to Rich Text — icon only */}
|
{state.isMarkdown && !readOnly && (
|
||||||
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
|
|
||||||
<button
|
<button
|
||||||
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||||
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
|
||||||
@@ -156,7 +139,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI — icon only */}
|
|
||||||
<button
|
<button
|
||||||
title="AI Assistant"
|
title="AI Assistant"
|
||||||
aria-label="Ouvrir l'assistant IA"
|
aria-label="Ouvrir l'assistant IA"
|
||||||
@@ -171,7 +153,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Save — icon only */}
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
|
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
|
||||||
@@ -189,7 +170,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Share button */}
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
title="Partager la note"
|
title="Partager la note"
|
||||||
@@ -201,8 +181,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Three-dot options menu */}
|
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -229,7 +207,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Share Dialog portal */}
|
|
||||||
{shareOpen && (
|
{shareOpen && (
|
||||||
<NoteShareDialog
|
<NoteShareDialog
|
||||||
noteId={note.id}
|
noteId={note.id}
|
||||||
@@ -238,7 +215,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info panel toggle — rightmost, icon only */}
|
|
||||||
<button
|
<button
|
||||||
aria-label="Informations du document"
|
aria-label="Informations du document"
|
||||||
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
|
||||||
@@ -256,32 +232,26 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dialog toolbar
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
<div className="flex items-center justify-between pt-3 border-t border-border/30">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<>
|
||||||
{/* Reminder */}
|
|
||||||
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
|
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
|
||||||
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/* Add Image */}
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Add Link */}
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
|
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
|
||||||
<LinkIcon className="h-4 w-4" />
|
<LinkIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
|
{state.isMarkdown && (
|
||||||
|
|
||||||
{state.noteType === 'markdown' && (
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
|
||||||
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
|
||||||
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
|
||||||
@@ -289,17 +259,13 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Copilot */}
|
<Button variant="ghost" size="sm"
|
||||||
{state.noteType !== 'checklist' && (
|
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
|
||||||
<Button variant="ghost" size="sm"
|
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
|
||||||
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
|
<span className="hidden sm:inline">IA Note</span>
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
</Button>
|
||||||
<span className="hidden sm:inline">IA Note</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Size Selector */}
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
|
||||||
@@ -319,7 +285,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Color Picker */}
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
|
||||||
@@ -338,7 +303,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Label Manager */}
|
|
||||||
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
|
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -394,4 +358,4 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { GhostTags } from '../ghost-tags'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
export function NoteMetadataSection() {
|
export function NoteMetadataSection() {
|
||||||
const { state, actions, readOnly } = useNoteEditorContext()
|
const { state, actions, readOnly, globalLabels } = useNoteEditorContext()
|
||||||
|
|
||||||
|
const getLabelType = (name: string): 'ai' | 'user' => {
|
||||||
|
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
|
||||||
|
return (found as any)?.type === 'ai' ? 'ai' : 'user'
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -17,6 +22,7 @@ export function NoteMetadataSection() {
|
|||||||
<LabelBadge
|
<LabelBadge
|
||||||
key={label}
|
key={label}
|
||||||
label={label}
|
label={label}
|
||||||
|
type={getLabelType(label)}
|
||||||
onRemove={() => actions.handleRemoveLabel(label)}
|
onRemove={() => actions.handleRemoveLabel(label)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -24,7 +30,7 @@ export function NoteMetadataSection() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Ghost Tags - only show in dialog mode */}
|
{/* Ghost Tags - only show in dialog mode */}
|
||||||
{!readOnly && state.noteType !== 'richtext' && (
|
{!readOnly && !state.isMarkdown && (
|
||||||
<GhostTags
|
<GhostTags
|
||||||
suggestions={state.filteredSuggestions}
|
suggestions={state.filteredSuggestions}
|
||||||
addedTags={state.labels}
|
addedTags={state.labels}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType, LinkMetadata, NoteSize } from '@/lib/types'
|
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
|
||||||
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||||
import type { TagSuggestion } from '@/lib/ai/types'
|
import type { TagSuggestion } from '@/lib/ai/types'
|
||||||
|
|
||||||
// State interface - all local state from NoteEditor
|
|
||||||
export interface NoteEditorState {
|
export interface NoteEditorState {
|
||||||
// Core content state
|
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
checkItems: CheckItem[]
|
checkItems: CheckItem[]
|
||||||
@@ -14,15 +12,12 @@ export interface NoteEditorState {
|
|||||||
newLabel: string
|
newLabel: string
|
||||||
color: NoteColor
|
color: NoteColor
|
||||||
size: NoteSize
|
size: NoteSize
|
||||||
noteType: NoteType
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
showMarkdownPreview: boolean
|
showMarkdownPreview: boolean
|
||||||
removedImageUrls: string[]
|
removedImageUrls: string[]
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
isDirty: boolean
|
isDirty: boolean
|
||||||
|
|
||||||
// AI state
|
|
||||||
isProcessingAI: boolean
|
isProcessingAI: boolean
|
||||||
aiOpen: boolean
|
aiOpen: boolean
|
||||||
infoOpen: boolean
|
infoOpen: boolean
|
||||||
@@ -37,107 +32,84 @@ export interface NoteEditorState {
|
|||||||
} | null
|
} | null
|
||||||
previousContentForCopilot: string | null
|
previousContentForCopilot: string | null
|
||||||
|
|
||||||
// Reminder state
|
|
||||||
showReminderDialog: boolean
|
showReminderDialog: boolean
|
||||||
currentReminder: Date | null
|
currentReminder: Date | null
|
||||||
|
|
||||||
// Link dialog state
|
|
||||||
showLinkDialog: boolean
|
showLinkDialog: boolean
|
||||||
linkUrl: string
|
linkUrl: string
|
||||||
|
|
||||||
// Memory Echo Connections
|
|
||||||
comparisonNotes: Array<Partial<Note>>
|
comparisonNotes: Array<Partial<Note>>
|
||||||
fusionNotes: Array<Partial<Note>>
|
fusionNotes: Array<Partial<Note>>
|
||||||
|
|
||||||
// Ghost tags
|
|
||||||
dismissedTags: string[]
|
dismissedTags: string[]
|
||||||
|
|
||||||
// Tag suggestions (from auto-tagging)
|
|
||||||
filteredSuggestions: TagSuggestion[]
|
filteredSuggestions: TagSuggestion[]
|
||||||
isAnalyzingSuggestions: boolean
|
isAnalyzingSuggestions: boolean
|
||||||
|
|
||||||
// Context-derived values
|
|
||||||
isMarkdown: boolean
|
isMarkdown: boolean
|
||||||
allImages: string[]
|
allImages: string[]
|
||||||
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions interface - all handlers from NoteEditor
|
|
||||||
export interface NoteEditorActions {
|
export interface NoteEditorActions {
|
||||||
// Title actions
|
|
||||||
setTitle: (title: string) => void
|
setTitle: (title: string) => void
|
||||||
setDismissedTitleSuggestions: (dismissed: boolean) => void
|
setDismissedTitleSuggestions: (dismissed: boolean) => void
|
||||||
|
|
||||||
// Content actions
|
|
||||||
setContent: (content: string) => void
|
setContent: (content: string) => void
|
||||||
|
|
||||||
// CheckItems actions
|
|
||||||
setCheckItems: (items: CheckItem[]) => void
|
setCheckItems: (items: CheckItem[]) => void
|
||||||
handleCheckItem: (id: string) => void
|
handleCheckItem: (id: string) => void
|
||||||
handleUpdateCheckItem: (id: string, text: string) => void
|
handleUpdateCheckItem: (id: string, text: string) => void
|
||||||
handleAddCheckItem: () => void
|
handleAddCheckItem: () => void
|
||||||
handleRemoveCheckItem: (id: string) => void
|
handleRemoveCheckItem: (id: string) => void
|
||||||
|
|
||||||
// Labels actions
|
|
||||||
setLabels: (labels: string[]) => void
|
setLabels: (labels: string[]) => void
|
||||||
handleSelectGhostTag: (tag: string) => void
|
handleSelectGhostTag: (tag: string) => void
|
||||||
handleDismissGhostTag: (tag: string) => void
|
handleDismissGhostTag: (tag: string) => void
|
||||||
handleRemoveLabel: (label: string) => void
|
handleRemoveLabel: (label: string) => void
|
||||||
|
|
||||||
// Images actions
|
|
||||||
setImages: (images: string[]) => void
|
setImages: (images: string[]) => void
|
||||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
|
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
handleRemoveImage: (index: number) => void
|
handleRemoveImage: (index: number) => void
|
||||||
uploadImageFile: (file: File) => Promise<string>
|
uploadImageFile: (file: File) => Promise<string>
|
||||||
|
|
||||||
// Links actions
|
|
||||||
setLinks: (links: LinkMetadata[]) => void
|
setLinks: (links: LinkMetadata[]) => void
|
||||||
handleAddLink: () => Promise<void>
|
handleAddLink: () => Promise<void>
|
||||||
handleRemoveLink: (index: number) => void
|
handleRemoveLink: (index: number) => void
|
||||||
|
|
||||||
// Note properties
|
|
||||||
setNoteType: (type: NoteType) => void
|
|
||||||
setShowMarkdownPreview: (show: boolean) => void
|
setShowMarkdownPreview: (show: boolean) => void
|
||||||
|
setIsMarkdown: (markdown: boolean) => void
|
||||||
setColor: (color: NoteColor) => void
|
setColor: (color: NoteColor) => void
|
||||||
setSize: (size: NoteSize) => void
|
setSize: (size: NoteSize) => void
|
||||||
|
|
||||||
// Reminder actions
|
|
||||||
setShowReminderDialog: (show: boolean) => void
|
setShowReminderDialog: (show: boolean) => void
|
||||||
setCurrentReminder: (date: Date | null) => void
|
setCurrentReminder: (date: Date | null) => void
|
||||||
handleReminderSave: (date: Date) => Promise<void>
|
handleReminderSave: (date: Date) => Promise<void>
|
||||||
handleRemoveReminder: () => Promise<void>
|
handleRemoveReminder: () => Promise<void>
|
||||||
|
|
||||||
// Link dialog
|
|
||||||
setShowLinkDialog: (show: boolean) => void
|
setShowLinkDialog: (show: boolean) => void
|
||||||
setLinkUrl: (url: string) => void
|
setLinkUrl: (url: string) => void
|
||||||
|
|
||||||
// Title suggestions
|
|
||||||
handleGenerateTitles: () => Promise<void>
|
handleGenerateTitles: () => Promise<void>
|
||||||
handleSelectTitle: (title: string) => void
|
handleSelectTitle: (title: string) => void
|
||||||
|
|
||||||
// Reformulation
|
|
||||||
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
|
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
|
||||||
handleApplyRefactor: () => void
|
handleApplyRefactor: () => void
|
||||||
|
|
||||||
// AI Direct handlers
|
|
||||||
handleClarifyDirect: () => Promise<void>
|
handleClarifyDirect: () => Promise<void>
|
||||||
handleShortenDirect: () => Promise<void>
|
handleShortenDirect: () => Promise<void>
|
||||||
handleImproveDirect: () => Promise<void>
|
handleImproveDirect: () => Promise<void>
|
||||||
handleTransformMarkdown: () => Promise<void>
|
handleTransformMarkdown: () => Promise<void>
|
||||||
|
|
||||||
// Save actions
|
|
||||||
handleSave: () => Promise<void>
|
handleSave: () => Promise<void>
|
||||||
handleSaveInPlace: () => Promise<void>
|
handleSaveInPlace: () => Promise<void>
|
||||||
handleMakeCopy: () => Promise<void>
|
handleMakeCopy: () => Promise<void>
|
||||||
|
|
||||||
// Memory Echo
|
|
||||||
setComparisonNotes: (notes: Array<Partial<Note>>) => void
|
setComparisonNotes: (notes: Array<Partial<Note>>) => void
|
||||||
setFusionNotes: (notes: Array<Partial<Note>>) => void
|
setFusionNotes: (notes: Array<Partial<Note>>) => void
|
||||||
|
|
||||||
// Modal states
|
|
||||||
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
|
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
|
||||||
|
|
||||||
// State setters
|
|
||||||
setIsDirty: (dirty: boolean) => void
|
setIsDirty: (dirty: boolean) => void
|
||||||
setAiOpen: (open: boolean) => void
|
setAiOpen: (open: boolean) => void
|
||||||
setInfoOpen: (open: boolean) => void
|
setInfoOpen: (open: boolean) => void
|
||||||
@@ -147,28 +119,14 @@ export interface NoteEditorActions {
|
|||||||
setPreviousContentForCopilot: (content: string | null) => void
|
setPreviousContentForCopilot: (content: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context value - combines state + actions + note reference
|
|
||||||
export interface NoteEditorContextValue {
|
export interface NoteEditorContextValue {
|
||||||
// The current note (external source of truth)
|
|
||||||
note: Note
|
note: Note
|
||||||
|
|
||||||
// Read-only flag
|
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
|
|
||||||
// FullPage flag
|
|
||||||
fullPage: boolean
|
fullPage: boolean
|
||||||
|
|
||||||
// All state
|
|
||||||
state: NoteEditorState
|
state: NoteEditorState
|
||||||
|
|
||||||
// All actions
|
|
||||||
actions: NoteEditorActions
|
actions: NoteEditorActions
|
||||||
|
|
||||||
// Computed values from contexts
|
|
||||||
notebooks: Array<{ id: string; name: string }>
|
notebooks: Array<{ id: string; name: string }>
|
||||||
globalLabels: Array<{ name: string }>
|
globalLabels: Array<{ name: string }>
|
||||||
|
|
||||||
// Refs
|
|
||||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useLanguage } from '@/lib/i18n'
|
|||||||
import { useRefresh } from '@/lib/use-refresh'
|
import { useRefresh } from '@/lib/use-refresh'
|
||||||
import { motion, AnimatePresence } from 'motion/react'
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen } from 'lucide-react'
|
import { ChevronRight, MoreHorizontal, Trash2, Archive, Pin, History, Pencil, Sparkles, Loader2, Bell, FolderOpen } from 'lucide-react'
|
||||||
|
import { useLabelsQuery } from '@/lib/query-hooks'
|
||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { getAISettings } from '@/app/actions/ai-settings'
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
import { generateNoteIllustrationSvg } from '@/app/actions/note-illustration'
|
||||||
@@ -275,7 +276,7 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="h-full w-full flex items-center justify-center relative overflow-hidden"
|
className="h-full w-full flex items-center justify-center relative overflow-hidden"
|
||||||
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% 94%) 0%, hsl(${hue} 18% 87%) 100%)` }}
|
style={{ background: `linear-gradient(145deg, hsl(${hue} 25% var(--thumb-lightness-1, 94%)) 0%, hsl(${hue} 18% var(--thumb-lightness-2, 87%)) 100%)` }}
|
||||||
>
|
>
|
||||||
{/* Decorative concentric circles */}
|
{/* Decorative concentric circles */}
|
||||||
<svg
|
<svg
|
||||||
@@ -314,6 +315,18 @@ function NoteThumbnailPlaceholder({ title, noteId }: { title: string; noteId: st
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NoteTag({ labelName, allLabels }: { labelName: string; allLabels: any[] }) {
|
||||||
|
const labelDef = allLabels?.find(l => l.name === labelName)
|
||||||
|
const isAI = labelDef?.type === 'ai'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-paper dark:bg-white/5 text-[9px] font-bold uppercase tracking-[0.15em] text-muted-foreground border border-border/40">
|
||||||
|
{isAI && <Sparkles size={8} className="text-blueprint" />}
|
||||||
|
{labelName}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function NotesEditorialView({
|
export function NotesEditorialView({
|
||||||
notes,
|
notes,
|
||||||
onOpen,
|
onOpen,
|
||||||
@@ -322,6 +335,7 @@ export function NotesEditorialView({
|
|||||||
}: NotesEditorialViewProps) {
|
}: NotesEditorialViewProps) {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
|
const { data: allLabels } = useLabelsQuery()
|
||||||
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
const [aiIllustrationEnabled, setAiIllustrationEnabled] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -374,6 +388,13 @@ export function NotesEditorialView({
|
|||||||
<div className="flex flex-col md:flex-row gap-8 items-start">
|
<div className="flex flex-col md:flex-row gap-8 items-start">
|
||||||
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
|
<EditorialThumbnail note={note} title={title} aiIllustrationEnabled={aiIllustrationEnabled} />
|
||||||
<div className="space-y-3 flex-1">
|
<div className="space-y-3 flex-1">
|
||||||
|
{note.labels && note.labels.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{note.labels.slice(0, 2).map((labelName) => (
|
||||||
|
<NoteTag key={labelName} labelName={labelName} allLabels={allLabels || []} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{excerpt ? (
|
{excerpt ? (
|
||||||
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
|
<p className="text-[14px] leading-relaxed text-foreground/80 font-light max-w-lg line-clamp-4">
|
||||||
{excerpt}
|
{excerpt}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
|
'flex items-center gap-2 pb-3 pt-4 text-[11px] font-bold uppercase tracking-[0.15em] transition-all whitespace-nowrap border-b-2',
|
||||||
isActive(section.href)
|
isActive(section.href)
|
||||||
? 'border-[#D4A373] text-[#1C1C1C]'
|
? 'border-[#D4A373] text-ink'
|
||||||
: 'border-transparent text-[#1C1C1C]/40 hover:text-[#1C1C1C]'
|
: 'border-transparent text-muted-ink hover:text-ink'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.icon}
|
{section.icon}
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
Pencil,
|
Pencil,
|
||||||
Clock,
|
Clock,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { applyDocumentTheme } from '@/lib/apply-document-theme'
|
||||||
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
import { getAllNotes, getTrashCount } from '@/app/actions/notes'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
@@ -151,7 +154,7 @@ function SidebarCarnetItem({
|
|||||||
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative',
|
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative',
|
||||||
isActive ? 'bg-white shadow-sm border border-border/40' : 'hover:bg-white/40'
|
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
@@ -163,9 +166,9 @@ function SidebarCarnetItem({
|
|||||||
)}
|
)}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all',
|
'w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border shrink-0 transition-all',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blueprint text-white border-blueprint'
|
? 'bg-blueprint text-white border-blueprint'
|
||||||
: 'bg-white/60 text-ink border-border'
|
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
|
||||||
)}>
|
)}>
|
||||||
{carnet.initial}
|
{carnet.initial}
|
||||||
</div>
|
</div>
|
||||||
@@ -260,6 +263,19 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
const [createParentId, setCreateParentId] = useState<string | null>(null)
|
||||||
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
|
const [renamingNotebook, setRenamingNotebook] = useState<Notebook | null>(null)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
|
const [isDark, setIsDark] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
const next = !isDark
|
||||||
|
setIsDark(next)
|
||||||
|
const theme = next ? 'dark' : 'light'
|
||||||
|
localStorage.setItem('theme-preference', theme)
|
||||||
|
applyDocumentTheme(theme)
|
||||||
|
}, [isDark])
|
||||||
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
const [deletingNotebook, setDeletingNotebook] = useState<Notebook | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [isRenaming, setIsRenaming] = useState(false)
|
const [isRenaming, setIsRenaming] = useState(false)
|
||||||
@@ -581,7 +597,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
{user?.image ? (
|
{user?.image ? (
|
||||||
<Avatar className="size-10 ring-1 ring-border/60">
|
<Avatar className="size-10 ring-1 ring-border/60">
|
||||||
<AvatarImage src={user.image} alt="" />
|
<AvatarImage src={user.image} alt="" />
|
||||||
<AvatarFallback className="bg-secondary text-sm font-semibold text-[#1C1C1C]/60">{initial}</AvatarFallback>
|
<AvatarFallback className="bg-secondary text-sm font-semibold text-muted-ink">{initial}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
) : (
|
) : (
|
||||||
<span>{initial}</span>
|
<span>{initial}</span>
|
||||||
@@ -621,10 +637,16 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Notification bell + Notebooks / Agents toggle */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 text-muted-foreground hover:text-foreground transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun size={14} /> : <Moon size={14} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
<NotificationPanel />
|
<NotificationPanel />
|
||||||
<div className="flex bg-white/50 p-1 rounded-full border border-border transition-all">
|
<div className="flex bg-white/50 dark:bg-white/5 p-1 rounded-full border border-border dark:border-white/10 transition-all">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
onClick={() => { setActiveView('notebooks'); if (pathname !== '/') router.push('/') }}
|
||||||
className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink')}
|
className={cn('p-1.5 rounded-full transition-all', activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink')}
|
||||||
@@ -721,7 +743,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium border shrink-0',
|
||||||
isInboxActive
|
isInboxActive
|
||||||
? 'bg-ink text-paper border-ink'
|
? 'bg-ink text-paper border-ink'
|
||||||
: 'bg-white/60 text-ink border-border'
|
: 'bg-white/60 dark:bg-white/5 text-ink dark:text-foreground border-border'
|
||||||
)}>
|
)}>
|
||||||
<Inbox size={14} />
|
<Inbox size={14} />
|
||||||
</div>
|
</div>
|
||||||
@@ -784,14 +806,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
'w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group',
|
||||||
isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-white/40 hover:text-foreground'
|
isActive ? 'memento-active-nav' : 'text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
'w-8 h-8 rounded-full flex items-center justify-center border transition-colors shrink-0',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-foreground text-background border-foreground'
|
? 'bg-foreground text-background border-foreground'
|
||||||
: 'bg-white/60 border-border group-hover:border-foreground/20'
|
: 'bg-paper border-border group-hover:border-foreground/20'
|
||||||
)}>
|
)}>
|
||||||
<item.icon size={16} />
|
<item.icon size={16} />
|
||||||
</div>
|
</div>
|
||||||
@@ -814,7 +836,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||||
searchParams.get('shared') === '1' && pathname === '/'
|
searchParams.get('shared') === '1' && pathname === '/'
|
||||||
? 'bg-blueprint/5 text-blueprint'
|
? 'bg-blueprint/5 text-blueprint'
|
||||||
: 'text-muted-ink hover:text-ink hover:bg-black/5'
|
: 'text-muted-ink hover:text-ink hover:bg-foreground/5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
|
<Users size={14} className={searchParams.get('shared') === '1' && pathname === '/' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
|
||||||
@@ -823,7 +845,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/archive"
|
href="/archive"
|
||||||
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl"
|
className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-foreground/5 transition-all font-medium group rounded-xl"
|
||||||
>
|
>
|
||||||
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
|
||||||
<span>{t('sidebar.archive')}</span>
|
<span>{t('sidebar.archive')}</span>
|
||||||
@@ -853,7 +875,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
|||||||
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
'w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl',
|
||||||
pathname.startsWith('/settings')
|
pathname.startsWith('/settings')
|
||||||
? 'bg-ink text-paper shadow-sm'
|
? 'bg-ink text-paper shadow-sm'
|
||||||
: 'text-muted-ink hover:text-ink hover:bg-black/5'
|
: 'text-muted-ink hover:text-ink hover:bg-foreground/5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Settings size={14} className={pathname.startsWith('/settings') ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
|
<Settings size={14} className={pathname.startsWith('/settings') ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ toolRegistry.register({
|
|||||||
limit: z.number().optional().describe('Max results to return (default 5)').default(5),
|
limit: z.number().optional().describe('Max results to return (default 5)').default(5),
|
||||||
notebookId: z.string().optional().describe('Optional notebook ID to restrict search to a specific notebook'),
|
notebookId: z.string().optional().describe('Optional notebook ID to restrict search to a specific notebook'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ query, limit = 5, notebookId }) => {
|
execute: async ({ query, limit = 5, notebookId: explicitNotebookId }) => {
|
||||||
|
// If no notebookId passed explicitly, fall back to the chat scope from context
|
||||||
|
const notebookId = explicitNotebookId || ctx.notebookId
|
||||||
try {
|
try {
|
||||||
// Keyword fallback search using Prisma
|
// Keyword fallback search using Prisma
|
||||||
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
|
const keywords = query.toLowerCase().split(/\s+/).filter(w => w.length > 2)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface Notebook {
|
|||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
trashedAt?: Date | null;
|
trashedAt?: Date | null;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
notes?: Note[];
|
notes?: Note[];
|
||||||
|
|||||||
Reference in New Issue
Block a user