0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
551 lines
30 KiB
TypeScript
551 lines
30 KiB
TypeScript
import React, { useEffect, useState, useCallback } from 'react'
|
|
import { Book, Plus, Search, Trash2, Edit2, Clock, Eye, EyeOff, X, ArrowLeft, Folder, FolderPlus, MoreVertical, Check, ChevronRight } from 'lucide-react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { noteService } from '../../services/noteService'
|
|
import { noteCategoryService } from '../../services/noteCategoryService'
|
|
import { Note, NoteCategory } from '../../types'
|
|
import { useLanguage } from '../../contexts/LanguageContext'
|
|
import { useToast } from '../../contexts/ToastContext'
|
|
import { useConfirm } from '../../contexts/ConfirmContext'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import remarkMath from 'remark-math'
|
|
import rehypeKatex from 'rehype-katex'
|
|
|
|
interface MemosViewProps {
|
|
authToken: string
|
|
isAdmin?: boolean
|
|
}
|
|
|
|
export const MemosView: React.FC<MemosViewProps> = ({ authToken, isAdmin = false }) => {
|
|
const { t } = useLanguage()
|
|
const { showError, showSuccess } = useToast()
|
|
const { confirm } = useConfirm()
|
|
|
|
const [notes, setNotes] = useState<Note[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [filterText, setFilterText] = useState('')
|
|
|
|
// Editor state
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [currentNote, setCurrentNote] = useState<Partial<Note>>({})
|
|
const [showPreview, setShowPreview] = useState(true)
|
|
|
|
// Category state
|
|
const [categories, setCategories] = useState<NoteCategory[]>([])
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null) // null = All
|
|
const [isCategoryLoading, setIsCategoryLoading] = useState(false)
|
|
const [showAddCategory, setShowAddCategory] = useState(false)
|
|
const [newCategoryName, setNewCategoryName] = useState('')
|
|
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null)
|
|
const [editCategoryName, setEditCategoryName] = useState('')
|
|
const [addingSubCategoryId, setAddingSubCategoryId] = useState<string | null>(null)
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
|
|
|
const fetchNotes = useCallback(async () => {
|
|
if (!authToken) return
|
|
try {
|
|
setIsLoading(true)
|
|
const data = await noteService.getAll(authToken, undefined, selectedCategoryId || undefined)
|
|
setNotes(data)
|
|
} catch (error: any) {
|
|
console.error(error)
|
|
const errorMsg = error.message || ''
|
|
if (!errorMsg.includes('401') && !errorMsg.includes('403')) {
|
|
showError(`${t('errorLoadData')}: ${errorMsg}`)
|
|
}
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [authToken, selectedCategoryId, showError, t])
|
|
|
|
const fetchCategories = useCallback(async () => {
|
|
if (!authToken) return
|
|
try {
|
|
setIsCategoryLoading(true)
|
|
const data = await noteCategoryService.getAll(authToken)
|
|
setCategories(data)
|
|
} catch (error: any) {
|
|
console.error(error)
|
|
} finally {
|
|
setIsCategoryLoading(false)
|
|
}
|
|
}, [authToken])
|
|
|
|
useEffect(() => {
|
|
fetchNotes()
|
|
}, [fetchNotes])
|
|
|
|
useEffect(() => {
|
|
fetchCategories()
|
|
}, [fetchCategories])
|
|
|
|
const handleSaveNote = async () => {
|
|
if (!currentNote.title || !currentNote.content) {
|
|
showError(t('errorTitleContentRequired'))
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (currentNote.id) {
|
|
await noteService.update(authToken, currentNote.id, {
|
|
title: currentNote.title,
|
|
content: currentNote.content,
|
|
categoryId: currentNote.categoryId
|
|
})
|
|
showSuccess(t('successNoteUpdated'))
|
|
} else {
|
|
await noteService.create(authToken, {
|
|
title: currentNote.title,
|
|
content: currentNote.content,
|
|
groupId: '',
|
|
categoryId: currentNote.categoryId || selectedCategoryId || undefined
|
|
})
|
|
showSuccess(t('successNoteCreated'))
|
|
}
|
|
setIsEditing(false)
|
|
setCurrentNote({})
|
|
fetchNotes()
|
|
} catch (error: any) {
|
|
showError(t('errorSaveFailed', error.message))
|
|
}
|
|
}
|
|
|
|
const handleDeleteNote = async (id: string) => {
|
|
if (!(await confirm(t('confirmDeleteNote')))) return
|
|
try {
|
|
await noteService.delete(authToken, id)
|
|
showSuccess(t('successNoteDeleted'))
|
|
fetchNotes()
|
|
} catch (error) {
|
|
showError(t('deleteFailed'))
|
|
}
|
|
}
|
|
|
|
const filteredNotes = notes.filter(n =>
|
|
n.title.toLowerCase().includes(filterText.toLowerCase()) ||
|
|
n.content.toLowerCase().includes(filterText.toLowerCase())
|
|
)
|
|
|
|
const handleCreateCategory = async (e: React.FormEvent, parentId?: string) => {
|
|
e.preventDefault()
|
|
if (!newCategoryName.trim()) return
|
|
try {
|
|
await noteCategoryService.create(authToken, newCategoryName.trim(), parentId)
|
|
setNewCategoryName('')
|
|
setShowAddCategory(false)
|
|
setAddingSubCategoryId(null)
|
|
fetchCategories()
|
|
showSuccess(t('categoryCreated'))
|
|
} catch (error: any) {
|
|
showError(`${t('failedToCreateCategory')}: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
const handleUpdateCategory = async (id: string) => {
|
|
if (!editCategoryName.trim()) return
|
|
try {
|
|
await noteCategoryService.update(authToken, id, editCategoryName.trim())
|
|
setEditingCategoryId(null)
|
|
fetchCategories()
|
|
showSuccess(t('groupUpdated'))
|
|
} catch (error) {
|
|
showError(t('actionFailed'))
|
|
}
|
|
}
|
|
|
|
const handleDeleteCategory = async (e: React.MouseEvent, id: string) => {
|
|
e.stopPropagation()
|
|
if (!(await confirm(t('confirmDeleteCategory')))) return
|
|
try {
|
|
await noteCategoryService.delete(authToken, id)
|
|
if (selectedCategoryId === id) setSelectedCategoryId(null)
|
|
fetchCategories()
|
|
showSuccess(t('groupDeleted'))
|
|
} catch (error) {
|
|
showError(t('failedToDeleteCategory'))
|
|
}
|
|
}
|
|
|
|
const toggleCategory = (id: string, e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
const newExpanded = new Set(expandedCategories)
|
|
if (newExpanded.has(id)) newExpanded.delete(id)
|
|
else newExpanded.add(id)
|
|
setExpandedCategories(newExpanded)
|
|
}
|
|
|
|
const renderCategoryTree = (parentId: string | null) => {
|
|
const items = categories.filter(c => (c.parentId || null) === (parentId || null))
|
|
|
|
return items.map(cat => {
|
|
const hasChildren = categories.some(c => c.parentId === cat.id)
|
|
const isExpanded = expandedCategories.has(cat.id)
|
|
|
|
return (
|
|
<div key={cat.id} className="flex flex-col">
|
|
<div className="group relative">
|
|
{editingCategoryId === cat.id ? (
|
|
<div className="flex items-center gap-2 bg-white border border-blue-200 rounded-lg p-1.5 mx-2">
|
|
<input
|
|
autoFocus
|
|
className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
|
|
value={editCategoryName}
|
|
onChange={e => setEditCategoryName(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleUpdateCategory(cat.id)}
|
|
/>
|
|
<button onClick={() => handleUpdateCategory(cat.id)} className="text-blue-600"><Check size={14} /></button>
|
|
<button onClick={() => setEditingCategoryId(null)} className="text-slate-400"><X size={14} /></button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={() => setSelectedCategoryId(cat.id)}
|
|
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'}`}
|
|
style={{ paddingLeft: `${(cat.level - 1) * 12 + 12}px` }}
|
|
>
|
|
<div className="flex items-center gap-2 overflow-hidden">
|
|
<div
|
|
onClick={(e) => toggleCategory(cat.id, e)}
|
|
className={`p-0.5 hover:bg-blue-100 rounded transition-colors ${!hasChildren ? 'invisible' : ''}`}
|
|
>
|
|
<ChevronRight size={14} className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} />
|
|
</div>
|
|
<Folder size={16} className={selectedCategoryId === cat.id ? 'text-blue-600' : 'text-slate-400 group-hover:text-blue-500'} />
|
|
<span className="truncate">{cat.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
|
{cat.level < 3 && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setAddingSubCategoryId(cat.id)
|
|
setShowAddCategory(true)
|
|
if (!isExpanded) {
|
|
const newExpanded = new Set(expandedCategories)
|
|
newExpanded.add(cat.id)
|
|
setExpandedCategories(newExpanded)
|
|
}
|
|
}}
|
|
className="p-1 hover:text-blue-600"
|
|
title={t('subFolderPlaceholder')}
|
|
>
|
|
<FolderPlus size={12} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setEditingCategoryId(cat.id)
|
|
setEditCategoryName(cat.name)
|
|
}}
|
|
className="p-1 hover:text-blue-600"
|
|
>
|
|
<Edit2 size={12} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => handleDeleteCategory(e, cat.id)}
|
|
className="p-1 hover:text-red-500"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div className="flex flex-col">
|
|
{renderCategoryTree(cat.id)}
|
|
{addingSubCategoryId === cat.id && (
|
|
<form
|
|
onSubmit={(e) => handleCreateCategory(e, cat.id)}
|
|
className="my-1 mx-2"
|
|
style={{ paddingLeft: `${cat.level * 12 + 12}px` }}
|
|
>
|
|
<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">
|
|
<input
|
|
autoFocus
|
|
className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
|
|
placeholder={t('subFolderPlaceholder')}
|
|
value={newCategoryName}
|
|
onChange={e => setNewCategoryName(e.target.value)}
|
|
onBlur={() => !newCategoryName && setAddingSubCategoryId(null)}
|
|
/>
|
|
<button type="submit" className="text-blue-600 hover:text-blue-800"><Check size={14} /></button>
|
|
<button type="button" onClick={() => setAddingSubCategoryId(null)} className="text-slate-400"><X size={14} /></button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<div className="flex flex-col h-full bg-white overflow-hidden">
|
|
<div className="px-8 pt-8 pb-6 flex items-center justify-between shrink-0 border-b border-slate-100">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => setIsEditing(false)}
|
|
className="p-2 -ml-2 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 transition-colors"
|
|
title={t('back')}
|
|
>
|
|
<ArrowLeft size={20} />
|
|
</button>
|
|
<div className="flex flex-col">
|
|
<h2 className="text-xl font-bold text-slate-900 leading-tight">
|
|
{currentNote.id ? t('editNote') : t('newNote')}
|
|
</h2>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('directoryLabel')}:</span>
|
|
<select
|
|
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"
|
|
value={currentNote.categoryId || ''}
|
|
onChange={(e) => setCurrentNote({ ...currentNote, categoryId: e.target.value || undefined })}
|
|
>
|
|
<option value="">{t('uncategorized')}</option>
|
|
{categories.map(c => {
|
|
const parent = categories.find(p => p.id === c.parentId)
|
|
const grandparent = parent ? categories.find(gp => gp.id === parent.parentId) : null
|
|
const path = [grandparent, parent, c].filter(Boolean).map(cat => cat?.name).join(' > ')
|
|
return (
|
|
<option key={c.id} value={c.id}>
|
|
{'\u00A0'.repeat((c.level - 1) * 2)}{c.name}
|
|
</option>
|
|
)
|
|
})}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setShowPreview(!showPreview)}
|
|
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"
|
|
>
|
|
{showPreview ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
{showPreview ? t('hidePreview') : t('showPreview')}
|
|
</button>
|
|
<button
|
|
onClick={handleSaveNote}
|
|
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"
|
|
>
|
|
{t('save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex overflow-hidden">
|
|
<div className={`flex-1 flex flex-col p-8 gap-6 ${showPreview ? 'border-r border-slate-100' : ''}`}>
|
|
<input
|
|
type="text"
|
|
placeholder={t('noteTitlePlaceholder')}
|
|
value={currentNote.title || ''}
|
|
onChange={(e) => setCurrentNote({ ...currentNote, title: e.target.value })}
|
|
className="text-2xl font-bold text-slate-900 bg-transparent border-none focus:ring-0 placeholder:text-slate-200 w-full"
|
|
/>
|
|
<textarea
|
|
placeholder={t('startWritingPlaceholder')}
|
|
value={currentNote.content || ''}
|
|
onChange={(e) => setCurrentNote({ ...currentNote, content: e.target.value })}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{showPreview && (
|
|
<div className="flex-1 p-8 overflow-y-auto bg-slate-50/20">
|
|
<div className="prose prose-slate prose-sm max-w-none">
|
|
<h1 className="text-2xl font-bold text-slate-900 mb-6">{currentNote.title || t('previewHeader')}</h1>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm, remarkMath]}
|
|
rehypePlugins={[rehypeKatex]}
|
|
>
|
|
{currentNote.content || t('noContentToPreview')}
|
|
</ReactMarkdown>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full bg-white overflow-hidden">
|
|
{/* Category Sidebar */}
|
|
<aside className="w-64 border-r border-slate-100 flex flex-col bg-slate-50/30 shrink-0">
|
|
<div className="p-6 pb-2">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-[11px] font-black text-slate-400 uppercase tracking-widest">{t('personalNotebook') || t('directoryLabel')}</h2>
|
|
<button
|
|
onClick={() => setShowAddCategory(true)}
|
|
className="p-1 hover:bg-slate-100 rounded-md text-slate-400 transition-colors"
|
|
>
|
|
<FolderPlus size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-0.5">
|
|
<button
|
|
onClick={() => setSelectedCategoryId(null)}
|
|
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'}`}
|
|
>
|
|
<Book size={16} className={!selectedCategoryId ? 'text-blue-600' : 'text-slate-400'} />
|
|
{t('allNotes')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-4 py-2">
|
|
{showAddCategory && !addingSubCategoryId && (
|
|
<form onSubmit={(e) => handleCreateCategory(e)} className="mb-4 px-2">
|
|
<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">
|
|
<input
|
|
autoFocus
|
|
className="flex-1 text-xs border-none outline-none p-0 focus:ring-0"
|
|
placeholder={t('enterNamePlaceholder')}
|
|
value={newCategoryName}
|
|
onChange={e => setNewCategoryName(e.target.value)}
|
|
onBlur={() => !newCategoryName && setShowAddCategory(false)}
|
|
/>
|
|
<button type="submit" className="text-blue-600 hover:text-blue-800"><Check size={14} /></button>
|
|
<button type="button" onClick={() => setShowAddCategory(false)} className="text-slate-400"><X size={14} /></button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
{renderCategoryTree(null)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 flex flex-col overflow-hidden relative">
|
|
<div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h1 className="text-2xl font-bold text-slate-900 leading-tight">{t('navNotebook')}</h1>
|
|
{selectedCategoryId && (
|
|
<>
|
|
<ChevronRight size={16} className="text-slate-300" />
|
|
<span className="text-2xl font-bold text-blue-600 truncate max-w-[200px]">
|
|
{categories.find(c => c.id === selectedCategoryId)?.name || t('directoryLabel')}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<p className="text-[15px] text-slate-500">{t('notebookDesc') || 'Capture your personal thoughts and research notes.'}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setCurrentNote({ categoryId: selectedCategoryId || undefined })
|
|
setIsEditing(true)
|
|
}}
|
|
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"
|
|
>
|
|
<Plus size={18} />
|
|
{t('newNote')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-8 pb-6 flex items-center shrink-0">
|
|
<div className="relative max-w-xs w-full">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder={t('filterNotesPlaceholder')}
|
|
value={filterText}
|
|
onChange={(e) => setFilterText(e.target.value)}
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-8 pb-8 flex-1 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
|
</div>
|
|
) : filteredNotes.length === 0 ? (
|
|
<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">
|
|
<Book className="w-12 h-12 text-slate-200 mx-auto mb-4" />
|
|
<h3 className="text-slate-900 font-bold">{t('noNotesFound') || 'No Notes Found'}</h3>
|
|
<p className="text-slate-500 text-sm mt-1">{t('startByCreatingNote') || 'Start by creating your first personal note.'}</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
<AnimatePresence>
|
|
{filteredNotes.map((note) => (
|
|
<motion.div
|
|
key={note.id}
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
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"
|
|
onClick={() => {
|
|
setCurrentNote(note)
|
|
setIsEditing(true)
|
|
}}
|
|
>
|
|
<div className="absolute top-4 right-4 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setCurrentNote(note)
|
|
setIsEditing(true)
|
|
}}
|
|
className="p-1.5 text-slate-400 hover:text-blue-600 rounded-md bg-slate-50"
|
|
>
|
|
<Edit2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleDeleteNote(note.id)
|
|
}}
|
|
className="p-1.5 text-slate-400 hover:text-red-500 rounded-md bg-slate-50"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<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">
|
|
<Book size={20} />
|
|
</div>
|
|
<h3 className="font-bold text-slate-900 text-[16px] mb-2 leading-tight group-hover:text-blue-600 transition-colors truncate pr-12">
|
|
{note.title}
|
|
</h3>
|
|
<p className="text-[13px] text-slate-500 leading-relaxed line-clamp-4 overflow-hidden">
|
|
{note.content}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-auto pt-4 border-t border-slate-50 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Clock size={12} className="text-slate-300" />
|
|
<span className="text-[11px] font-medium text-slate-400">
|
|
{new Date(note.updatedAt).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
{note.categoryId && (
|
|
<span className="text-[10px] bg-slate-100 text-slate-500 px-2 py-0.5 rounded font-medium">
|
|
{categories.find(c => c.id === note.categoryId)?.name || t('directoryLabel')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|