refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client
This commit is contained in:
@@ -16,6 +16,9 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { LabelBadge } from '@/components/label-badge'
|
||||
import { EditorConnectionsSection } from '@/components/editor-connections-section'
|
||||
import { FusionModal } from '@/components/fusion-modal'
|
||||
import { ComparisonModal } from '@/components/comparison-modal'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -24,6 +27,9 @@ import {
|
||||
toggleArchive,
|
||||
updateColor,
|
||||
deleteNote,
|
||||
removeImageFromNote,
|
||||
leaveSharedNote,
|
||||
createNote,
|
||||
} from '@/app/actions/notes'
|
||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||
import {
|
||||
@@ -49,6 +55,8 @@ import {
|
||||
RotateCcw,
|
||||
Languages,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
@@ -58,6 +66,7 @@ import { GhostTags } from '@/components/ghost-tags'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { TitleSuggestions } from '@/components/title-suggestions'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
@@ -99,8 +108,11 @@ export function NoteInlineEditor({
|
||||
defaultPreviewMode = false,
|
||||
}: NoteInlineEditorProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const { labels: globalLabels, addLabel } = useLabels()
|
||||
const { labels: globalLabels, addLabel, refreshLabels } = useLabels()
|
||||
const [, startTransition] = useTransition()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const isSharedNote = !!(note as any)._isShared
|
||||
|
||||
// ── Local edit state ──────────────────────────────────────────────────────
|
||||
const [title, setTitle] = useState(note.title || '')
|
||||
@@ -113,11 +125,41 @@ export function NoteInlineEditor({
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [dismissedTags, setDismissedTags] = useState<string[]>([])
|
||||
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
|
||||
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
|
||||
|
||||
const changeTitle = (t: string) => { setTitle(t); onChange?.(note.id, { title: t }) }
|
||||
const changeContent = (c: string) => { setContent(c); onChange?.(note.id, { content: c }) }
|
||||
const changeCheckItems = (ci: CheckItem[]) => { setCheckItems(ci); onChange?.(note.id, { checkItems: ci }) }
|
||||
|
||||
// Textarea ref for formatting toolbar
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const applyFormat = (prefix: string, suffix: string = prefix) => {
|
||||
const textarea = textAreaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = content.substring(start, end)
|
||||
const before = content.substring(0, start)
|
||||
const after = content.substring(end)
|
||||
|
||||
const newContent = before + prefix + selected + suffix + after
|
||||
changeContent(newContent)
|
||||
scheduleSave()
|
||||
|
||||
// Restore cursor position after React re-renders
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
const newCursorPos = selected ? end + prefix.length + suffix.length : start + prefix.length
|
||||
textarea.setSelectionRange(
|
||||
selected ? start + prefix.length : start + prefix.length,
|
||||
selected ? end + prefix.length : newCursorPos
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Link dialog
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const [showLinkInput, setShowLinkInput] = useState(false)
|
||||
@@ -230,12 +272,103 @@ export function NoteInlineEditor({
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
const globalExists = globalLabels.some((l) => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try { await addLabel(tag) } catch {}
|
||||
try {
|
||||
await addLabel(tag)
|
||||
// Refresh labels to get the new color assignment
|
||||
await refreshLabels()
|
||||
} catch {}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLabel = async (label: string) => {
|
||||
const newLabels = (note.labels || []).filter((l) => l !== label)
|
||||
// Optimistic UI
|
||||
onChange?.(note.id, { labels: newLabels })
|
||||
await updateNote(note.id, { labels: newLabels }, { skipRevalidation: true })
|
||||
toast.success(t('labels.labelRemoved', { label }))
|
||||
}
|
||||
|
||||
// ── Shared note actions ────────────────────────────────────────────────────
|
||||
const handleMakeCopy = async () => {
|
||||
try {
|
||||
await createNote({
|
||||
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||
content,
|
||||
color: note.color,
|
||||
type: note.type,
|
||||
checkItems: note.checkItems ?? undefined,
|
||||
labels: note.labels ?? undefined,
|
||||
images: note.images ?? undefined,
|
||||
links: note.links ?? undefined,
|
||||
isMarkdown,
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
triggerRefresh()
|
||||
} catch (error) {
|
||||
toast.error(t('notes.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleLeaveShare = async () => {
|
||||
try {
|
||||
await leaveSharedNote(note.id)
|
||||
toast.success(t('notes.leftShare') || 'Share removed')
|
||||
triggerRefresh()
|
||||
onDelete?.(note.id)
|
||||
} catch (error) {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMergeNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setFusionNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
const handleCompareNotes = async (noteIds: string[]) => {
|
||||
const fetched = await Promise.all(noteIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${id}`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return data.success && data.data ? data.data : null
|
||||
} catch { return null }
|
||||
}))
|
||||
setComparisonNotes(fetched.filter((n: any) => n !== null) as Array<Partial<Note>>)
|
||||
}
|
||||
|
||||
const handleConfirmFusion = async ({ title, content }: { title: string; content: string }, options: { archiveOriginals: boolean; keepAllTags: boolean; useLatestTitle: boolean; createBacklinks: boolean }) => {
|
||||
await createNote({
|
||||
title,
|
||||
content,
|
||||
labels: options.keepAllTags
|
||||
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
|
||||
: fusionNotes[0].labels || [],
|
||||
color: fusionNotes[0].color,
|
||||
type: 'text',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
notebookId: fusionNotes[0].notebookId ?? undefined
|
||||
})
|
||||
if (options.archiveOriginals) {
|
||||
for (const n of fusionNotes) {
|
||||
if (n.id) await updateNote(n.id, { isArchived: true }, { skipRevalidation: true })
|
||||
}
|
||||
}
|
||||
toast.success(t('toast.notesFusionSuccess'))
|
||||
setFusionNotes([])
|
||||
triggerRefresh()
|
||||
}
|
||||
|
||||
// ── Quick actions (pin, archive, color, delete) ───────────────────────────
|
||||
const handleTogglePin = () => {
|
||||
startTransition(async () => {
|
||||
@@ -262,10 +395,21 @@ export function NoteInlineEditor({
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirm(t('notes.confirmDelete'))) return
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
toast(t('notes.confirmDelete'), {
|
||||
action: {
|
||||
label: t('notes.delete'),
|
||||
onClick: () => {
|
||||
startTransition(async () => {
|
||||
await deleteNote(note.id)
|
||||
onDelete?.(note.id)
|
||||
})
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
label: t('common.cancel'),
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -293,7 +437,7 @@ export function NoteInlineEditor({
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const newImages = (note.images || []).filter((_, i) => i !== index)
|
||||
onChange?.(note.id, { images: newImages })
|
||||
await updateNote(note.id, { images: newImages })
|
||||
await removeImageFromNote(note.id, index)
|
||||
}
|
||||
|
||||
// ── Link ──────────────────────────────────────────────────────────────────
|
||||
@@ -437,7 +581,27 @@ export function NoteInlineEditor({
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
|
||||
{/* ── Toolbar ────────────────────────────────────────────────────────── */}
|
||||
{/* ── Shared note banner ──────────────────────────────────────────── */}
|
||||
{isSharedNote && (
|
||||
<div className="flex items-center justify-between border-b border-border/30 bg-primary/5 dark:bg-primary/10 px-4 py-2">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{t('notes.sharedReadOnly') || 'Lecture seule — note partagée'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="default" size="sm" className="h-7 gap-1.5 text-xs" onClick={handleMakeCopy}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t('notes.makeCopy') || 'Copier'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30" onClick={handleLeaveShare}>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
{t('notes.leaveShare') || 'Quitter'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Toolbar (hidden for shared notes) ────────────────────────────── */}
|
||||
{!isSharedNote && (
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border/30 px-4 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Image upload */}
|
||||
@@ -625,12 +789,6 @@ export function NoteInlineEditor({
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Pin */}
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
title={note.isPinned ? t('notes.unpin') : t('notes.pin')} onClick={handleTogglePin}>
|
||||
<Pin className={cn('h-4 w-4', note.isPinned && 'fill-current text-primary')} />
|
||||
</Button>
|
||||
|
||||
{/* Color picker */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -677,6 +835,7 @@ export function NoteInlineEditor({
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Link input bar (inline) ───────────────────────────────────────── */}
|
||||
{showLinkInput && (
|
||||
@@ -704,7 +863,11 @@ export function NoteInlineEditor({
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/20 px-8 py-2">
|
||||
{/* Existing labels */}
|
||||
{(note.labels ?? []).map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => handleRemoveLabel(label)}
|
||||
/>
|
||||
))}
|
||||
{/* AI-suggested tags inline with labels */}
|
||||
<GhostTags
|
||||
@@ -728,6 +891,7 @@ export function NoteInlineEditor({
|
||||
placeholder={t('notes.titlePlaceholder') || 'Titre…'}
|
||||
value={title}
|
||||
onChange={(e) => { changeTitle(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
/>
|
||||
{/* AI title suggestion — show when title is empty and there's content */}
|
||||
{!title && content.trim().split(/\s+/).filter(Boolean).length >= 5 && (
|
||||
@@ -812,17 +976,21 @@ export function NoteInlineEditor({
|
||||
<MarkdownContent content={content || ''} />
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
<>
|
||||
<textarea
|
||||
ref={textAreaRef}
|
||||
dir="auto"
|
||||
className="flex-1 w-full resize-none bg-transparent text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||
placeholder={isMarkdown
|
||||
? t('notes.takeNoteMarkdown') || 'Écris en Markdown…'
|
||||
: t('notes.takeNote') || 'Écris quelque chose…'
|
||||
}
|
||||
value={content}
|
||||
onChange={(e) => { changeContent(e.target.value); scheduleSave() }}
|
||||
readOnly={isSharedNote}
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Ghost tag suggestions are now shown in the top labels strip */}
|
||||
@@ -881,6 +1049,15 @@ export function NoteInlineEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Memory Echo Connections Section (not for shared notes) ── */}
|
||||
{!isSharedNote && (
|
||||
<EditorConnectionsSection
|
||||
noteId={note.id}
|
||||
onMergeNotes={handleMergeNotes}
|
||||
onCompareNotes={handleCompareNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────────────────── */}
|
||||
@@ -891,6 +1068,25 @@ export function NoteInlineEditor({
|
||||
<span>{t('notes.created') || 'Créée'} {formatDistanceToNow(new Date(note.createdAt), { addSuffix: true, locale: dateLocale })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fusion Modal */}
|
||||
{fusionNotes.length > 0 && (
|
||||
<FusionModal
|
||||
isOpen={fusionNotes.length > 0}
|
||||
onClose={() => setFusionNotes([])}
|
||||
notes={fusionNotes}
|
||||
onConfirmFusion={handleConfirmFusion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Comparison Modal */}
|
||||
{comparisonNotes.length > 0 && (
|
||||
<ComparisonModal
|
||||
isOpen={comparisonNotes.length > 0}
|
||||
onClose={() => setComparisonNotes([])}
|
||||
notes={comparisonNotes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user