MemosView.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import React, { useEffect, useState, useCallback } from 'react'
  2. import { Book, Plus, Search, Trash2, Edit2, Clock, Eye, EyeOff, X, ArrowLeft, Folder, FolderPlus, MoreVertical, Check, ChevronRight } from 'lucide-react'
  3. import { motion, AnimatePresence } from 'framer-motion'
  4. import { noteService } from '../../services/noteService'
  5. import { noteCategoryService } from '../../services/noteCategoryService'
  6. import { Note, NoteCategory } from '../../types'
  7. import { useLanguage } from '../../contexts/LanguageContext'
  8. import { useToast } from '../../contexts/ToastContext'
  9. import { useConfirm } from '../../contexts/ConfirmContext'
  10. import ReactMarkdown from 'react-markdown'
  11. import remarkGfm from 'remark-gfm'
  12. import remarkMath from 'remark-math'
  13. import rehypeKatex from 'rehype-katex'
  14. interface MemosViewProps {
  15. authToken: string
  16. isAdmin?: boolean
  17. }
  18. export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false }) => {
  19. const { t } = useLanguage()
  20. const { showError, showSuccess } = useToast()
  21. const { confirm } = useConfirm()
  22. const [notes, setNotes] = useState<Note[]>([])
  23. const [isLoading, setIsLoading] = useState(true)
  24. const [filterText, setFilterText] = useState('')
  25. // Editor state
  26. const [isEditing, setIsEditing] = useState(false)
  27. const [currentNote, setCurrentNote] = useState<Partial<Note>>({})
  28. const [showPreview, setShowPreview] = useState(true)
  29. // Category state
  30. const [categories, setCategories] = useState<NoteCategory[]>([])
  31. const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null) // null = All
  32. const [isCategoryLoading, setIsCategoryLoading] = useState(false)
  33. const [showAddCategory, setShowAddCategory] = useState(false)
  34. const [newCategoryName, setNewCategoryName] = useState('')
  35. const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
  36. const [editCategoryName, setEditCategoryName] = useState('')
  37. const [addingSubCategoryId, setAddingSubCategoryId] = useState<string | null>(null)
  38. const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
  39. const fetchNotes = useCallback(async () => {
  40. if (!authToken) return
  41. try {
  42. setIsLoading(true)
  43. const data = await noteService.getAll(authToken, undefined, selectedCategoryId || undefined)
  44. setNotes(data)
  45. } catch (error: any) {
  46. console.error(error)
  47. const errorMsg = error.message || ''
  48. if (!errorMsg.includes('401') && !errorMsg.includes('403')) {
  49. showError(`${t('errorLoadData')}: ${errorMsg}`)
  50. }
  51. } finally {
  52. setIsLoading(false)
  53. }
  54. }, [authToken, selectedCategoryId, showError, t])
  55. const fetchCategories = useCallback(async () => {
  56. if (!authToken) return
  57. try {
  58. setIsCategoryLoading(true)
  59. const data = await noteCategoryService.getAll(authToken)
  60. setCategories(data)
  61. } catch (error: any) {
  62. console.error(error)
  63. } finally {
  64. setIsCategoryLoading(false)
  65. }
  66. }, [authToken])
  67. useEffect(() => {
  68. fetchNotes()
  69. }, [fetchNotes])
  70. useEffect(() => {
  71. fetchCategories()
  72. }, [fetchCategories])
  73. const handleSaveNote = async () => {
  74. if (!currentNote.title || !currentNote.content) {
  75. showError(t('errorTitleContentRequired'))
  76. return
  77. }
  78. try {
  79. if (currentNote.id) {
  80. await noteService.update(authToken, currentNote.id, {
  81. title: currentNote.title,
  82. content: currentNote.content,
  83. categoryId: currentNote.categoryId
  84. })
  85. showSuccess(t('successNoteUpdated'))
  86. } else {
  87. await noteService.create(authToken, {
  88. title: currentNote.title,
  89. content: currentNote.content,
  90. groupId: '',
  91. categoryId: currentNote.categoryId || selectedCategoryId || undefined
  92. })
  93. showSuccess(t('successNoteCreated'))
  94. }
  95. setIsEditing(false)
  96. setCurrentNote({})
  97. fetchNotes()
  98. } catch (error: any) {
  99. showError(t('errorSaveFailed', error.message))
  100. }
  101. }
  102. const handleDeleteNote = async (id: string) => {
  103. if (!(await confirm(t('confirmDeleteNote')))) return
  104. try {
  105. await noteService.delete(authToken, id)
  106. showSuccess(t('successNoteDeleted'))
  107. fetchNotes()
  108. } catch (error) {
  109. showError(t('deleteFailed'))
  110. }
  111. }
  112. const filteredNotes = notes.filter(n =>
  113. n.title.toLowerCase().includes(filterText.toLowerCase()) ||
  114. n.content.toLowerCase().includes(filterText.toLowerCase())
  115. )
  116. const handleCreateCategory = async (e: React.FormEvent, parentId?: string) => {
  117. e.preventDefault()
  118. if (!newCategoryName.trim()) return
  119. try {
  120. await noteCategoryService.create(authToken, newCategoryName.trim(), parentId)
  121. setNewCategoryName('')
  122. setShowAddCategory(false)
  123. setAddingSubCategoryId(null)
  124. fetchCategories()
  125. showSuccess(t('categoryCreated'))
  126. } catch (error: any) {
  127. showError(`${t('failedToCreateCategory')}: ${error.message}`)
  128. }
  129. }
  130. const handleUpdateCategory = async (id: string) => {
  131. if (!editCategoryName.trim()) return
  132. try {
  133. await noteCategoryService.update(authToken, id, editCategoryName.trim())
  134. setEditingCategoryId(null)
  135. fetchCategories()
  136. showSuccess(t('groupUpdated'))
  137. } catch (error) {
  138. showError(t('actionFailed'))
  139. }
  140. }
  141. const handleDeleteCategory = async (e: React.MouseEvent, id: string) => {
  142. e.stopPropagation()
  143. if (!(await confirm(t('confirmDeleteCategory')))) return
  144. try {
  145. await noteCategoryService.delete(authToken, id)
  146. if (selectedCategoryId === id) setSelectedCategoryId(null)
  147. fetchCategories()
  148. showSuccess(t('groupDeleted'))
  149. } catch (error) {
  150. showError(t('failedToDeleteCategory'))
  151. }
  152. }
  153. const toggleCategory = (id: string, e: React.MouseEvent) => {
  154. e.stopPropagation()
  155. const newExpanded = new Set(expandedCategories)
  156. if (newExpanded.has(id)) newExpanded.delete(id)
  157. else newExpanded.add(id)
  158. setExpandedCategories(newExpanded)
  159. }
  160. const renderCategoryTree = (parentId: string | null) => {
  161. const items = categories.filter(c => (c.parentId || null) === (parentId || null))
  162. return items.map(cat => {
  163. const hasChildren = categories.some(c => c.parentId === cat.id)
  164. const isExpanded = expandedCategories.has(cat.id)
  165. return (
  166. <div key={cat.id} className="flex flex-col">
  167. <div className="group relative">
  168. {editingCategoryId === cat.id ? (
  169. <div className="flex items-center gap-2 bg-white border border-blue-200 rounded-lg p-1.5 mx-2">
  170. <input
  171. autoFocus
  172. className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
  173. value={editCategoryName}
  174. onChange={e => setEditCategoryName(e.target.value)}
  175. onKeyDown={e => e.key === 'Enter' && handleUpdateCategory(cat.id)}
  176. />
  177. <button onClick={() => handleUpdateCategory(cat.id)} className="text-blue-600"><Check size={14} /></button>
  178. <button onClick={() => setEditingCategoryId(null)} className="text-slate-400"><X size={14} /></button>
  179. </div>
  180. ) : (
  181. <div
  182. onClick={() => setSelectedCategoryId(cat.id)}
  183. className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm group cursor-pointer transition-all ${selectedCategoryId === cat.id ? 'bg-blue-50 text-blue-700 font-bold shadow-sm' : 'text-slate-500 hover:bg-slate-100/50'}`}
  184. style={{ paddingLeft: `${(cat.level - 1) * 12 + 12}px` }}
  185. >
  186. <div className="flex items-center gap-2 overflow-hidden">
  187. <div
  188. onClick={(e) => toggleCategory(cat.id, e)}
  189. className={`p-0.5 hover:bg-blue-100 rounded transition-colors ${!hasChildren ? 'invisible' : ''}`}
  190. >
  191. <ChevronRight size={14} className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} />
  192. </div>
  193. <Folder size={16} className={selectedCategoryId === cat.id ? 'text-blue-600' : 'text-slate-400 group-hover:text-blue-500'} />
  194. <span className="truncate">{cat.name}</span>
  195. </div>
  196. <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
  197. {cat.level < 3 && (
  198. <button
  199. onClick={(e) => {
  200. e.stopPropagation()
  201. setAddingSubCategoryId(cat.id)
  202. setShowAddCategory(true)
  203. if (!isExpanded) {
  204. const newExpanded = new Set(expandedCategories)
  205. newExpanded.add(cat.id)
  206. setExpandedCategories(newExpanded)
  207. }
  208. }}
  209. className="p-1 hover:text-blue-600"
  210. title={t('subFolderPlaceholder')}
  211. >
  212. <FolderPlus size={12} />
  213. </button>
  214. )}
  215. <button
  216. onClick={(e) => {
  217. e.stopPropagation()
  218. setEditingCategoryId(cat.id)
  219. setEditCategoryName(cat.name)
  220. }}
  221. className="p-1 hover:text-blue-600"
  222. >
  223. <Edit2 size={12} />
  224. </button>
  225. <button
  226. onClick={(e) => handleDeleteCategory(e, cat.id)}
  227. className="p-1 hover:text-red-500"
  228. >
  229. <Trash2 size={12} />
  230. </button>
  231. </div>
  232. </div>
  233. )}
  234. </div>
  235. {isExpanded && (
  236. <div className="flex flex-col">
  237. {renderCategoryTree(cat.id)}
  238. {addingSubCategoryId === cat.id && (
  239. <form
  240. onSubmit={(e) => handleCreateCategory(e, cat.id)}
  241. className="my-1 mx-2"
  242. style={{ paddingLeft: `${cat.level * 12 + 12}px` }}
  243. >
  244. <div className="flex items-center gap-2 bg-white border border-blue-200 rounded-lg p-1.5 focus-within:ring-2 focus-within:ring-blue-100 transition-all">
  245. <input
  246. autoFocus
  247. className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
  248. placeholder={t('subFolderPlaceholder')}
  249. value={newCategoryName}
  250. onChange={e => setNewCategoryName(e.target.value)}
  251. onBlur={() => !newCategoryName && setAddingSubCategoryId(null)}
  252. />
  253. <button type="submit" className="text-blue-600 hover:text-blue-800"><Check size={14} /></button>
  254. <button type="button" onClick={() => setAddingSubCategoryId(null)} className="text-slate-400"><X size={14} /></button>
  255. </div>
  256. </form>
  257. )}
  258. </div>
  259. )}
  260. </div>
  261. )
  262. })
  263. }
  264. if (isEditing) {
  265. return (
  266. <div className="flex flex-col h-full bg-white overflow-hidden">
  267. <div className="px-8 pt-8 pb-6 flex items-center justify-between shrink-0 border-b border-slate-100">
  268. <div className="flex items-center gap-4">
  269. <button
  270. onClick={() => setIsEditing(false)}
  271. className="p-2 -ml-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
  272. title={t('back')}
  273. >
  274. <ArrowLeft size={20} />
  275. </button>
  276. <div className="flex flex-col">
  277. <h2 className="text-xl font-bold text-slate-900 leading-tight">
  278. {currentNote.id ? t('editNote') : t('newNote')}
  279. </h2>
  280. <div className="flex items-center gap-2 mt-1">
  281. <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('directoryLabel')}:</span>
  282. <select
  283. className="text-[11px] font-bold text-blue-600 bg-blue-50/50 px-2 py-0.5 rounded border-none outline-none focus:ring-0 cursor-pointer max-w-[150px] truncate"
  284. value={currentNote.categoryId || ''}
  285. onChange={(e) => setCurrentNote({ ...currentNote, categoryId: e.target.value || undefined })}
  286. >
  287. <option value="">{t('uncategorized')}</option>
  288. {categories.map(c => {
  289. const parent = categories.find(p => p.id === c.parentId)
  290. const grandparent = parent ? categories.find(gp => gp.id === parent.parentId) : null
  291. const path = [grandparent, parent, c].filter(Boolean).map(cat => cat?.name).join(' > ')
  292. return (
  293. <option key={c.id} value={c.id}>
  294. {'\u00A0'.repeat((c.level - 1) * 2)}{c.name}
  295. </option>
  296. )
  297. })}
  298. </select>
  299. </div>
  300. </div>
  301. </div>
  302. <div className="flex items-center gap-3">
  303. <button
  304. onClick={() => setShowPreview(!showPreview)}
  305. className="flex items-center gap-2 px-3 py-1.5 text-[13px] font-semibold text-slate-500 hover:bg-slate-50 rounded-lg transition-all"
  306. >
  307. {showPreview ? <EyeOff size={16} /> : <Eye size={16} />}
  308. {showPreview ? t('hidePreview') : t('showPreview')}
  309. </button>
  310. <button
  311. onClick={handleSaveNote}
  312. className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-semibold text-sm shadow-sm transition-all active:scale-95"
  313. >
  314. {t('save')}
  315. </button>
  316. </div>
  317. </div>
  318. <div className="flex-1 flex overflow-hidden">
  319. <div className={`flex-1 flex flex-col p-8 gap-6 ${showPreview ? 'border-r border-slate-100' : ''}`}>
  320. <input
  321. type="text"
  322. placeholder={t('noteTitlePlaceholder')}
  323. value={currentNote.title || ''}
  324. onChange={(e) => setCurrentNote({ ...currentNote, title: e.target.value })}
  325. className="text-2xl font-bold text-slate-900 bg-transparent border-none focus:ring-0 placeholder:text-slate-200 w-full"
  326. />
  327. <textarea
  328. placeholder={t('startWritingPlaceholder')}
  329. value={currentNote.content || ''}
  330. onChange={(e) => setCurrentNote({ ...currentNote, content: e.target.value })}
  331. className="flex-1 text-[15px] text-slate-700 bg-transparent border-none focus:ring-0 placeholder:text-slate-200 w-full resize-none leading-relaxed"
  332. />
  333. </div>
  334. {showPreview && (
  335. <div className="flex-1 p-8 overflow-y-auto bg-slate-50/20">
  336. <div className="prose prose-slate prose-sm max-w-none">
  337. <h1 className="text-2xl font-bold text-slate-900 mb-6">{currentNote.title || t('previewHeader')}</h1>
  338. <ReactMarkdown
  339. remarkPlugins={[remarkGfm, remarkMath]}
  340. rehypePlugins={[rehypeKatex]}
  341. >
  342. {currentNote.content || t('noContentToPreview')}
  343. </ReactMarkdown>
  344. </div>
  345. </div>
  346. )}
  347. </div>
  348. </div>
  349. )
  350. }
  351. return (
  352. <div className="flex h-full bg-white overflow-hidden">
  353. {/* Category Sidebar */}
  354. <aside className="w-64 border-r border-slate-100 flex flex-col bg-slate-50/30 shrink-0">
  355. <div className="p-6 pb-2">
  356. <div className="flex items-center justify-between mb-4">
  357. <h2 className="text-[11px] font-black text-slate-400 uppercase tracking-widest">{t('personalNotebook') || t('directoryLabel')}</h2>
  358. <button
  359. onClick={() => setShowAddCategory(true)}
  360. className="p-1 hover:bg-slate-100 rounded-md text-slate-400 transition-colors"
  361. >
  362. <FolderPlus size={16} />
  363. </button>
  364. </div>
  365. <div className="space-y-0.5">
  366. <button
  367. onClick={() => setSelectedCategoryId(null)}
  368. className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all ${!selectedCategoryId ? 'bg-blue-50 text-blue-700 font-bold shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}
  369. >
  370. <Book size={16} className={!selectedCategoryId ? 'text-blue-600' : 'text-slate-400'} />
  371. {t('allNotes')}
  372. </button>
  373. </div>
  374. </div>
  375. <div className="flex-1 overflow-y-auto px-4 py-2">
  376. {showAddCategory && !addingSubCategoryId && (
  377. <form onSubmit={(e) => handleCreateCategory(e)} className="mb-4 px-2">
  378. <div className="flex items-center gap-2 bg-white border border-blue-200 rounded-lg p-1.5 focus-within:ring-2 focus-within:ring-blue-100 transition-all">
  379. <input
  380. autoFocus
  381. className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
  382. placeholder={t('enterNamePlaceholder')}
  383. value={newCategoryName}
  384. onChange={e => setNewCategoryName(e.target.value)}
  385. onBlur={() => !newCategoryName && setShowAddCategory(false)}
  386. />
  387. <button type="submit" className="text-blue-600 hover:text-blue-800"><Check size={14} /></button>
  388. <button type="button" onClick={() => setShowAddCategory(false)} className="text-slate-400"><X size={14} /></button>
  389. </div>
  390. </form>
  391. )}
  392. <div className="space-y-1">
  393. {renderCategoryTree(null)}
  394. </div>
  395. </div>
  396. </aside>
  397. {/* Main Content */}
  398. <div className="flex-1 flex flex-col overflow-hidden relative">
  399. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  400. <div>
  401. <div className="flex items-center gap-2 mb-1">
  402. <h1 className="text-2xl font-bold text-slate-900 leading-tight">{t('navNotebook')}</h1>
  403. {selectedCategoryId && (
  404. <>
  405. <ChevronRight size={16} className="text-slate-300" />
  406. <span className="text-2xl font-bold text-blue-600 truncate max-w-[200px]">
  407. {categories.find(c => c.id === selectedCategoryId)?.name || t('directoryLabel')}
  408. </span>
  409. </>
  410. )}
  411. </div>
  412. <p className="text-[15px] text-slate-500">{t('notebookDesc') || 'Capture your personal thoughts and research notes.'}</p>
  413. </div>
  414. <button
  415. onClick={() => {
  416. setCurrentNote({ categoryId: selectedCategoryId || undefined })
  417. setIsEditing(true)
  418. }}
  419. className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm shadow-blue-100 transition-all font-semibold text-sm active:scale-95"
  420. >
  421. <Plus size={18} />
  422. {t('newNote')}
  423. </button>
  424. </div>
  425. <div className="px-8 pb-6 flex items-center shrink-0">
  426. <div className="relative max-w-xs w-full">
  427. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
  428. <input
  429. type="text"
  430. placeholder={t('filterNotesPlaceholder')}
  431. value={filterText}
  432. onChange={(e) => setFilterText(e.target.value)}
  433. className="w-full h-9 pl-9 pr-4 bg-white border border-slate-200 rounded-lg text-sm transition-all focus:ring-1 focus:ring-blue-500 focus:border-blue-500 outline-none"
  434. />
  435. </div>
  436. </div>
  437. <div className="px-8 pb-8 flex-1 overflow-y-auto">
  438. {isLoading ? (
  439. <div className="flex items-center justify-center py-20">
  440. <div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
  441. </div>
  442. ) : filteredNotes.length === 0 ? (
  443. <div className="flex flex-col items-center justify-center py-32 border-2 border-dashed border-slate-200 rounded-2xl bg-slate-50/10 text-center">
  444. <Book className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  445. <h3 className="text-slate-900 font-bold">{t('noNotesFound') || 'No Notes Found'}</h3>
  446. <p className="text-slate-500 text-sm mt-1">{t('startByCreatingNote') || 'Start by creating your first personal note.'}</p>
  447. </div>
  448. ) : (
  449. <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  450. <AnimatePresence>
  451. {filteredNotes.map((note) => (
  452. <motion.div
  453. key={note.id}
  454. layout
  455. initial={{ opacity: 0, scale: 0.95 }}
  456. animate={{ opacity: 1, scale: 1 }}
  457. className="bg-white rounded-xl border border-slate-200/80 p-5 shadow-sm hover:shadow-md transition-all group relative overflow-hidden flex flex-col h-64 cursor-pointer"
  458. onClick={() => {
  459. setCurrentNote(note)
  460. setIsEditing(true)
  461. }}
  462. >
  463. <div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
  464. <button
  465. onClick={(e) => {
  466. e.stopPropagation()
  467. setCurrentNote(note)
  468. setIsEditing(true)
  469. }}
  470. className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50"
  471. >
  472. <Edit2 size={16} />
  473. </button>
  474. <button
  475. onClick={(e) => {
  476. e.stopPropagation()
  477. handleDeleteNote(note.id)
  478. }}
  479. className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50"
  480. >
  481. <Trash2 size={16} />
  482. </button>
  483. </div>
  484. <div className="flex-1">
  485. <div className="w-11 h-11 bg-slate-50 rounded-lg text-slate-400 flex items-center justify-center mb-4 transition-all group-hover:bg-blue-600 group-hover:text-white">
  486. <Book size={20} />
  487. </div>
  488. <h3 className="font-bold text-slate-900 text-[16px] mb-2 leading-tight group-hover:text-blue-600 transition-colors truncate pr-12">
  489. {note.title}
  490. </h3>
  491. <p className="text-[13px] text-slate-500 leading-relaxed line-clamp-4 overflow-hidden">
  492. {note.content}
  493. </p>
  494. </div>
  495. <div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
  496. <div className="flex items-center gap-2">
  497. <Clock size={12} className="text-slate-300" />
  498. <span className="text-[11px] font-medium text-slate-400">
  499. {new Date(note.updatedAt).toLocaleDateString()}
  500. </span>
  501. </div>
  502. {note.categoryId && (
  503. <span className="text-[10px] bg-slate-100 text-slate-500 px-2 py-0.5 rounded font-medium">
  504. {categories.find(c => c.id === note.categoryId)?.name || t('directoryLabel')}
  505. </span>
  506. )}
  507. </div>
  508. </motion.div>
  509. ))}
  510. </AnimatePresence>
  511. </div>
  512. )}
  513. </div>
  514. </div>
  515. </div>
  516. )
  517. }