refactor(ux): consolidate BMAD skills, update design system, and clean up Prisma generated client

This commit is contained in:
Sepehr Ramezani
2026-04-19 19:21:27 +02:00
parent 5296c4da2c
commit 25529a24b8
2476 changed files with 127934 additions and 101962 deletions

View File

@@ -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>
)
}