MikiVL 9c534a920d feat(M5+): 导入导出功能、性能优化、单元测试
导出:Markdown / Word(.docx) / PDF(打印) / 纯文本,编辑器标题栏下拉菜单
导入:.md / .txt / .docx(mammoth) / .pdf(pdfjs-dist),侧边栏底部按钮
Store:createNote 支持 init 参数,filteredNotes 增加缓存层
测试:vitest 23 个单元测试(utils + filterNotes 逻辑)
构建:vite manualChunks 分包优化

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 01:16:11 +08:00

601 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useRef, useEffect } from 'react'
import { AnimatePresence } from 'framer-motion'
import {
Search, Plus, Star, FileText, Folder, FolderOpen,
ChevronRight, ChevronDown,
Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen,
ArrowUpDown, Check, X, Bot, Upload,
} from 'lucide-react'
import { ModelSettingsModal } from '../ai/ModelSettingsModal'
import { importMarkdown, importTxt, importDocx, importPDF } from '../../lib/import'
import {
DndContext, DragOverlay, useDraggable, useDroppable,
useSensor, useSensors, MouseSensor, TouchSensor,
type DragEndEvent, type DragStartEvent,
} from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { useAppStore } from '../../stores/appStore'
import { formatDate } from '../../lib/utils'
import type { Folder as FolderType, Note } from '../../db'
export function Sidebar() {
const {
notes, folders, activeNoteId, activeFolderId, searchQuery,
theme, createNote, createFolder, deleteNote, deleteFolder,
updateNote, updateFolder, setActiveNote, setActiveFolder, setSearch,
toggleTheme, toggleStar, filteredNotes,
activeTag, setActiveTag, sortBy, sortOrder, setSortBy, setSortOrder,
} = useAppStore()
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())
const [editingFolderId, setEditingFolderId] = useState<string | null>(null)
const [newFolderName, setNewFolderName] = useState('')
const [creatingFolder, setCreatingFolder] = useState(false)
const [newFolderInput, setNewFolderInput] = useState('')
const [editingNoteId, setEditingNoteId] = useState<string | null>(null)
const [noteEditValue, setNoteEditValue] = useState('')
const [sortMenuOpen, setSortMenuOpen] = useState(false)
const [modelModalOpen, setModelModalOpen] = useState(false)
const [localSearch, setLocalSearch] = useState(searchQuery)
const [contextMenu, setContextMenu] = useState<{
type: 'note' | 'folder'
id: string
x: number
y: number
} | null>(null)
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null)
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 6 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
)
const editInputRef = useRef<HTMLInputElement | null>(null)
const noteEditRef = useRef<HTMLInputElement | null>(null)
const newFolderRef = useRef<HTMLInputElement | null>(null)
const displayed = filteredNotes()
useEffect(() => {
const t = setTimeout(() => setSearch(localSearch), 300)
return () => clearTimeout(t)
}, [localSearch, setSearch])
useEffect(() => {
if (editingFolderId && editInputRef.current) editInputRef.current.focus()
}, [editingFolderId])
useEffect(() => {
if (editingNoteId && noteEditRef.current) noteEditRef.current.focus()
}, [editingNoteId])
useEffect(() => {
if (creatingFolder && newFolderRef.current) newFolderRef.current.focus()
}, [creatingFolder])
useEffect(() => {
const close = () => { setContextMenu(null); setSortMenuOpen(false) }
window.addEventListener('click', close)
return () => window.removeEventListener('click', close)
}, [])
const toggleFolder = (id: string) => {
setExpandedFolders(s => {
const next = new Set(s)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
const handleRenameFolder = async (id: string) => {
if (!newFolderName.trim()) return
await updateFolder(id, { name: newFolderName.trim() })
setEditingFolderId(null)
}
const handleRenameNote = async (id: string) => {
await updateNote(id, { title: noteEditValue.trim() || '无标题笔记' })
setEditingNoteId(null)
}
const handleCreateFolder = async () => {
if (!newFolderInput.trim()) { setCreatingFolder(false); return }
await createFolder(newFolderInput.trim())
setNewFolderInput('')
setCreatingFolder(false)
}
const handleNewNote = async () => {
const folderId = typeof activeFolderId === 'string' && activeFolderId !== 'all' && activeFolderId !== 'starred'
? activeFolderId
: null
await createNote(folderId)
}
const importFileRef = useRef<HTMLInputElement>(null)
const handleImportFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
const folderId = typeof activeFolderId === 'string' && activeFolderId !== 'all' && activeFolderId !== 'starred'
? activeFolderId
: null
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
let result: { title: string; content: string }
if (ext === 'md' || ext === 'markdown') {
result = importMarkdown(await file.text())
} else if (ext === 'txt') {
result = importTxt(await file.text())
} else if (ext === 'docx') {
result = await importDocx(await file.arrayBuffer())
} else if (ext === 'pdf') {
result = await importPDF(await file.arrayBuffer())
} else {
return
}
await createNote(folderId, result)
}
const handleDragStart = (event: DragStartEvent) => {
setDraggingNoteId(event.active.id as string)
}
const handleDragEnd = (event: DragEndEvent) => {
setDraggingNoteId(null)
if (event.over) {
updateNote(event.active.id as string, { folderId: event.over.id as string })
}
}
const draggingNote = draggingNoteId ? notes.find(n => n.id === draggingNoteId) : null
const renderFolder = (parentId: string | null, depth: number): React.ReactNode =>
folders
.filter(f => f.parentId === parentId)
.map(folder => (
<div key={folder.id}>
<DroppableFolderItem
folder={folder}
depth={depth}
expanded={expandedFolders.has(folder.id)}
active={activeFolderId === folder.id}
editing={editingFolderId === folder.id}
editValue={newFolderName}
editRef={editInputRef}
onToggle={() => toggleFolder(folder.id)}
onClick={() => setActiveFolder(folder.id)}
onContextMenu={(e) => {
e.preventDefault()
setContextMenu({ type: 'folder', id: folder.id, x: e.clientX, y: e.clientY })
}}
onEditChange={setNewFolderName}
onEditBlur={() => handleRenameFolder(folder.id)}
onEditKeyDown={(e) => {
if (e.key === 'Enter') handleRenameFolder(folder.id)
if (e.key === 'Escape') setEditingFolderId(null)
}}
/>
{expandedFolders.has(folder.id) && renderFolder(folder.id, depth + 1)}
</div>
))
const SORT_OPTIONS: { by: typeof sortBy; order: typeof sortOrder; label: string }[] = [
{ by: 'updatedAt', order: 'desc', label: '最近修改' },
{ by: 'createdAt', order: 'desc', label: '最近创建' },
{ by: 'title', order: 'asc', label: '标题 AZ' },
{ by: 'title', order: 'desc', label: '标题 ZA' },
]
const currentSort = SORT_OPTIONS.find(o => o.by === sortBy && o.order === sortOrder)
return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<aside
className="flex flex-col h-full shrink-0 border-r"
style={{ width: 260, background: 'var(--bg-subtle)', borderColor: 'var(--border)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--border)' }}>
<span className="font-semibold text-sm" style={{ color: 'var(--text)' }}></span>
<div className="flex items-center gap-1">
<button onClick={toggleTheme} className="toolbar-btn" title={theme === 'light' ? '暗色模式' : '亮色模式'}>
{theme === 'light' ? <Moon size={15} /> : <Sun size={15} />}
</button>
<button onClick={handleNewNote} className="toolbar-btn" title="新建笔记">
<Plus size={15} />
</button>
</div>
</div>
{/* Search */}
<div className="px-3 py-2">
<div
className="flex items-center gap-2 rounded-lg px-3 py-1.5"
style={{ background: 'var(--bg-muted)', border: '1px solid var(--border)' }}
>
<Search size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
placeholder="搜索笔记…"
className="bg-transparent outline-none text-sm w-full"
style={{ color: 'var(--text)' }}
/>
</div>
</div>
{/* Nav shortcuts */}
<nav className="px-2 space-y-0.5">
<NavItem icon={<BookOpen size={14} />} label="使用指南" active={activeNoteId === '__welcome__'} onClick={() => setActiveNote('__welcome__')} />
<NavItem icon={<FileText size={14} />} label="所有笔记" count={notes.length} active={activeFolderId === 'all' && activeNoteId !== '__welcome__'} onClick={() => { setActiveFolder('all'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
<NavItem icon={<Star size={14} />} label="收藏" count={notes.filter(n => n.starred).length} active={activeFolderId === 'starred'} onClick={() => { setActiveFolder('starred'); if (activeNoteId === '__welcome__') setActiveNote(null) }} />
</nav>
{/* Folders section */}
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}></span>
<button onClick={() => setCreatingFolder(true)} className="toolbar-btn" title="新建文件夹">
<FolderPlus size={13} />
</button>
</div>
<div className="px-2 space-y-0.5">
{renderFolder(null, 0)}
{creatingFolder && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg" style={{ background: 'var(--bg-muted)' }}>
<Folder size={14} style={{ color: 'var(--accent)' }} />
<input
ref={newFolderRef}
value={newFolderInput}
onChange={e => setNewFolderInput(e.target.value)}
onBlur={handleCreateFolder}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') { setCreatingFolder(false); setNewFolderInput('') }
}}
placeholder="文件夹名称"
className="bg-transparent outline-none text-sm flex-1"
style={{ color: 'var(--text)' }}
/>
</div>
)}
</div>
{/* Note list */}
<div className="flex-1 overflow-y-auto mt-2" style={{ borderTop: '1px solid var(--border)' }}>
<div className="px-3 py-2 flex items-center justify-between sticky top-0 z-10" style={{ background: 'var(--bg-subtle)' }}>
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
{displayed.length > 0 ? ` (${displayed.length})` : ''}
</span>
{activeTag && (
<span
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full cursor-pointer"
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
onClick={() => setActiveTag(null)}
>
<Hash size={9} />{activeTag}<X size={9} />
</span>
)}
</div>
<div className="relative">
<button
className="toolbar-btn"
title={`排序:${currentSort?.label ?? ''}`}
onClick={e => { e.stopPropagation(); setSortMenuOpen(v => !v) }}
>
<ArrowUpDown size={13} />
</button>
{sortMenuOpen && (
<div
className="absolute right-0 top-full mt-1 rounded-lg py-1 z-50 shadow-xl"
style={{ background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 140 }}
onClick={e => e.stopPropagation()}
>
{SORT_OPTIONS.map(opt => (
<button
key={opt.label}
onClick={() => { setSortBy(opt.by); setSortOrder(opt.order); setSortMenuOpen(false) }}
className="w-full flex items-center justify-between gap-2 px-3 py-1.5 text-sm text-left"
style={{ color: opt.by === sortBy && opt.order === sortOrder ? 'var(--accent)' : 'var(--text)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{opt.label}
{opt.by === sortBy && opt.order === sortOrder && <Check size={12} />}
</button>
))}
</div>
)}
</div>
</div>
<div className="px-2 pb-3 space-y-0.5">
{displayed.length === 0 && (
<div className="text-center py-8" style={{ color: 'var(--text-faint)' }}>
<FileText size={24} className="mx-auto mb-2 opacity-40" />
<p className="text-xs"></p>
</div>
)}
{displayed.map(note => (
<DraggableNoteItem
key={note.id}
note={note}
active={activeNoteId === note.id}
editing={editingNoteId === note.id}
editValue={noteEditValue}
editRef={noteEditRef}
activeTag={activeTag}
onEditChange={setNoteEditValue}
onEditBlur={() => handleRenameNote(note.id)}
onEditKeyDown={(e) => {
if (e.key === 'Enter') handleRenameNote(note.id)
if (e.key === 'Escape') setEditingNoteId(null)
}}
onClick={() => setActiveNote(note.id)}
onDelete={() => deleteNote(note.id)}
onToggleStar={() => toggleStar(note.id)}
onTagClick={(tag) => setActiveTag(activeTag === tag ? null : tag)}
onContextMenu={(e) => {
e.preventDefault()
setContextMenu({ type: 'note', id: note.id, x: e.clientX, y: e.clientY })
}}
/>
))}
</div>
</div>
{/* Context menu */}
{contextMenu && ( <div
className="fixed z-50 rounded-lg py-1 shadow-xl"
style={{ top: contextMenu.y, left: contextMenu.x, background: 'var(--bg)', border: '1px solid var(--border)', minWidth: 160 }}
onClick={e => e.stopPropagation()}
>
{contextMenu.type === 'folder' && (
<>
<CtxItem icon={<Edit2 size={13} />} label="重命名" onClick={() => {
const f = folders.find(f => f.id === contextMenu.id)
if (f) { setNewFolderName(f.name); setEditingFolderId(contextMenu.id) }
setContextMenu(null)
}} />
<CtxItem icon={<Trash2 size={13} />} label="删除文件夹" danger onClick={async () => { await deleteFolder(contextMenu.id); setContextMenu(null) }} />
</>
)}
{contextMenu.type === 'note' && (
<>
<CtxItem icon={<Edit2 size={13} />} label="重命名" onClick={() => {
const n = notes.find(n => n.id === contextMenu.id)
if (n) { setNoteEditValue(n.title); setEditingNoteId(contextMenu.id) }
setContextMenu(null)
}} />
<CtxItem icon={<Trash2 size={13} />} label="删除笔记" danger onClick={async () => { await deleteNote(contextMenu.id); setContextMenu(null) }} />
</>
)}
</div>
)}
{/* Bottom toolbar */}
<div
className="px-3 py-2 shrink-0 flex items-center justify-between"
style={{ borderTop: '1px solid var(--border)' }}
>
<button
onClick={() => setModelModalOpen(true)}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs transition-colors"
style={{ color: 'var(--text-faint)' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-muted)'; e.currentTarget.style.color = 'var(--text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Bot size={13} />
AI
</button>
<button
onClick={() => importFileRef.current?.click()}
className="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-xs transition-colors"
style={{ color: 'var(--text-faint)' }}
title="导入文件 (.md .txt .docx .pdf)"
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-muted)'; e.currentTarget.style.color = 'var(--text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Upload size={13} />
</button>
<input
ref={importFileRef}
type="file"
accept=".md,.markdown,.txt,.docx,.pdf"
style={{ display: 'none' }}
onChange={handleImportFile}
/>
</div>
</aside>
<AnimatePresence>
{modelModalOpen && <ModelSettingsModal onClose={() => setModelModalOpen(false)} />}
</AnimatePresence>
{/* Drag overlay */}
<DragOverlay>
{draggingNote && (
<div
className="px-2 py-2 rounded-lg text-sm font-medium opacity-90 shadow-lg"
style={{ background: 'var(--bg)', border: '1px solid var(--accent)', color: 'var(--text)', width: 220 }}
>
{draggingNote.title || '无标题笔记'}
</div>
)}
</DragOverlay>
</DndContext>
)
}
function NavItem({ icon, label, count, active, onClick }: { icon: React.ReactNode; label: string; count?: number; active: boolean; onClick: () => void }) {
return (
<button onClick={onClick} className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition-colors"
style={{ background: active ? 'var(--accent-subtle)' : 'transparent', color: active ? 'var(--accent)' : 'var(--text-muted)', fontWeight: active ? 500 : 400 }}>
{icon}
<span className="flex-1">{label}</span>
{(count !== undefined && count > 0) && (
<span className="text-xs px-1.5 py-0.5 rounded-full" style={{ background: 'var(--bg-muted)', color: 'var(--text-faint)' }}>{count}</span>
)}
</button>
)
}
function DroppableFolderItem({ folder, depth, expanded, active, editing, editValue, editRef, onToggle, onClick, onContextMenu, onEditChange, onEditBlur, onEditKeyDown }: {
folder: FolderType; depth: number; expanded: boolean; active: boolean; editing: boolean
editValue: string; editRef: React.RefObject<HTMLInputElement | null>
onToggle: () => void; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void
onEditChange: (v: string) => void; onEditBlur: () => void; onEditKeyDown: (e: React.KeyboardEvent) => void
}) {
const { isOver, setNodeRef } = useDroppable({ id: folder.id })
return (
<div
ref={setNodeRef}
className="flex items-center gap-1.5 py-1.5 rounded-lg cursor-pointer text-sm"
style={{
paddingLeft: 8 + depth * 16,
background: isOver ? 'var(--accent-subtle)' : active ? 'var(--accent-subtle)' : 'transparent',
color: active || isOver ? 'var(--accent)' : 'var(--text-muted)',
outline: isOver ? '2px solid var(--accent)' : 'none',
outlineOffset: -2,
}}
onClick={onClick}
onContextMenu={onContextMenu}
>
<button onClick={e => { e.stopPropagation(); onToggle() }} className="toolbar-btn" style={{ width: 16, height: 16, flexShrink: 0 }}>
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{expanded
? <FolderOpen size={14} style={{ color: 'var(--accent)', flexShrink: 0 }} />
: <Folder size={14} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />}
{editing ? (
<input ref={editRef} value={editValue} onChange={e => onEditChange(e.target.value)} onBlur={onEditBlur} onKeyDown={onEditKeyDown}
className="bg-transparent outline-none flex-1 text-sm" style={{ color: 'var(--text)' }} onClick={e => e.stopPropagation()} />
) : (
<span className="flex-1 truncate" style={{ fontWeight: active ? 500 : 400 }}>{folder.name}</span>
)}
</div>
)
}
function CtxItem({ icon, label, danger, onClick }: { icon: React.ReactNode; label: string; danger?: boolean; onClick: () => void }) {
return (
<button onClick={onClick} className="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left"
style={{ color: danger ? '#ef4444' : 'var(--text)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--bg-muted)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
{icon}{label}
</button>
)
}
function DraggableNoteItem({ note, active, editing, editValue, editRef, activeTag, onEditChange, onEditBlur, onEditKeyDown, onClick, onDelete, onToggleStar, onTagClick, onContextMenu }: {
note: Note
active: boolean
editing: boolean
editValue: string
editRef: React.RefObject<HTMLInputElement | null>
activeTag: string | null
onEditChange: (v: string) => void
onEditBlur: () => void
onEditKeyDown: (e: React.KeyboardEvent) => void
onClick: () => void
onDelete: () => void
onToggleStar: () => void
onTagClick: (tag: string) => void
onContextMenu: (e: React.MouseEvent) => void
}) {
const [hovered, setHovered] = useState(false)
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: note.id })
const style = {
transform: CSS.Translate.toString(transform),
opacity: isDragging ? 0.4 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
}
return (
<div
ref={setNodeRef}
style={{
...style,
background: active ? 'var(--accent-subtle)' : hovered ? 'var(--bg-muted)' : 'transparent',
borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
}}
className="px-2 py-2 rounded-lg relative"
onClick={onClick}
onContextMenu={onContextMenu}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
{...attributes}
{...listeners}
>
<div className="flex items-start justify-between gap-1">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{note.starred && <Star size={11} style={{ color: '#f59e0b', flexShrink: 0 }} fill="currentColor" />}
{editing ? (
<input
ref={editRef}
value={editValue}
onChange={e => onEditChange(e.target.value)}
onBlur={onEditBlur}
onKeyDown={onEditKeyDown}
className="bg-transparent outline-none text-sm font-medium flex-1 min-w-0"
style={{ color: 'var(--text)' }}
onClick={e => e.stopPropagation()}
/>
) : (
<span className="text-sm font-medium truncate" style={{ color: active ? 'var(--accent)' : 'var(--text)' }}>
{note.title || '无标题笔记'}
</span>
)}
</div>
{hovered ? (
<div className="flex items-center gap-0.5 shrink-0">
<button
className="toolbar-btn"
style={{ width: 20, height: 20, color: note.starred ? '#f59e0b' : 'var(--text-faint)' }}
title={note.starred ? '取消收藏' : '收藏'}
onClick={(e) => { e.stopPropagation(); onToggleStar() }}
>
<Star size={12} fill={note.starred ? '#f59e0b' : 'none'} />
</button>
<button
className="toolbar-btn"
style={{ width: 20, height: 20, color: '#ef4444', opacity: 0.8 }}
title="删除笔记"
onClick={(e) => { e.stopPropagation(); onDelete() }}
onMouseEnter={e => (e.currentTarget.style.opacity = '1')}
onMouseLeave={e => (e.currentTarget.style.opacity = '0.8')}
>
<Trash2 size={12} />
</button>
</div>
) : (
<span className="text-xs shrink-0 mt-0.5" style={{ color: 'var(--text-faint)' }}>
{formatDate(note.updatedAt)}
</span>
)}
</div>
{note.tags.length > 0 && (
<div className="flex gap-1 mt-1 flex-wrap">
{note.tags.slice(0, 3).map(tag => (
<span
key={tag}
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full cursor-pointer transition-colors"
style={{
background: activeTag === tag ? 'var(--accent-subtle)' : 'var(--bg-muted)',
color: activeTag === tag ? 'var(--accent)' : 'var(--text-muted)',
}}
onClick={e => { e.stopPropagation(); onTagClick(tag) }}
>
<Hash size={9} />{tag}
</span>
))}
</div>
)}
</div>
)
}