更新管理端用户详情弹窗,新增 VIP 手动设置功能,支持到期日、展示名、项目、联系方式和简介的编辑。优化 VIP 相关接口,确保用户状态和资料更新功能正常,增强用户体验。调整文档,明确 VIP 设置的必填项和格式要求。

This commit is contained in:
Alex-larget
2026-02-26 17:35:52 +08:00
parent 4f4e4407f7
commit ab27acdb21
214 changed files with 10477 additions and 36105 deletions

View File

@@ -15,6 +15,7 @@ import { SitePage } from './pages/site/SitePage'
import { QRCodesPage } from './pages/qrcodes/QRCodesPage'
import { MatchPage } from './pages/match/MatchPage'
import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
function App() {
@@ -37,6 +38,7 @@ function App() {
<Route path="qrcodes" element={<QRCodesPage />} />
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>

View File

@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import {
User,
Phone,
@@ -24,6 +25,7 @@ import {
Save,
X,
Tag,
Crown,
} from 'lucide-react'
import { get, put, post } from '@/api/client'
@@ -53,6 +55,13 @@ interface UserDetail {
tags?: string
ckbTags?: string
ckbSyncedAt?: string
isVip?: boolean
vipExpireDate?: string | null
vipName?: string | null
vipAvatar?: string | null
vipProject?: string | null
vipContact?: string | null
vipBio?: string | null
}
interface UserTrack {
@@ -82,6 +91,12 @@ export function UserDetailModal({
const [editNickname, setEditNickname] = useState('')
const [editTags, setEditTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
const [editIsVip, setEditIsVip] = useState(false)
const [editVipExpireDate, setEditVipExpireDate] = useState('')
const [editVipName, setEditVipName] = useState('')
const [editVipProject, setEditVipProject] = useState('')
const [editVipContact, setEditVipContact] = useState('')
const [editVipBio, setEditVipBio] = useState('')
useEffect(() => {
if (open && userId) loadUserDetail()
@@ -100,6 +115,12 @@ export function UserDetailModal({
setEditPhone(u.phone || '')
setEditNickname(u.nickname || '')
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
setEditIsVip(!!u.isVip)
setEditVipExpireDate(u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '')
setEditVipName(u.vipName || '')
setEditVipProject(u.vipProject || '')
setEditVipContact(u.vipContact || '')
setEditVipBio(u.vipBio || '')
}
try {
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
@@ -152,14 +173,32 @@ export function UserDetailModal({
async function handleSave() {
if (!user) return
if (editIsVip && !editVipExpireDate.trim()) {
alert('开启 VIP 时请填写有效到期日')
return
}
if (editIsVip && editVipExpireDate.trim()) {
const d = new Date(editVipExpireDate)
if (isNaN(d.getTime())) {
alert('到期日格式无效,请使用 YYYY-MM-DD')
return
}
}
setSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
const payload: Record<string, unknown> = {
id: user.id,
phone: editPhone || undefined,
nickname: editNickname || undefined,
tags: JSON.stringify(editTags),
})
isVip: editIsVip,
vipExpireDate: editVipExpireDate || undefined,
vipName: editVipName || undefined,
vipProject: editVipProject || undefined,
vipContact: editVipContact || undefined,
vipBio: editVipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
alert('保存成功')
loadUserDetail()
@@ -292,6 +331,70 @@ export function UserDetailModal({
/>
</div>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
<div className="flex items-center gap-2 mb-3">
<Crown className="w-4 h-4 text-amber-400" />
<span className="text-white font-medium">VIP </span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between">
<Label className="text-gray-300">VIP </Label>
<Switch
checked={editIsVip}
onCheckedChange={setEditIsVip}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">
(YYYY-MM-DD)
{editIsVip && <span className="text-amber-400 ml-1">*</span>}
</Label>
<Input
type="date"
className="bg-[#162840] border-gray-700 text-white"
value={editVipExpireDate}
onChange={(e) => setEditVipExpireDate(e.target.value)}
required={editIsVip}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">VIP </Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="创业老板排行展示名"
value={editVipName}
onChange={(e) => setEditVipName(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">/</Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="项目名称"
value={editVipProject}
onChange={(e) => setEditVipProject(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="微信号或手机"
value={editVipContact}
onChange={(e) => setEditVipContact(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="简要描述业务"
value={editVipBio}
onChange={(e) => setEditVipBio(e.target.value)}
/>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>

View File

@@ -32,13 +32,13 @@ function Slider({
)}
{...props}
>
<SliderPrimitive.Track className="bg-muted relative grow overflow-hidden rounded-full h-1.5 w-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
<SliderPrimitive.Track className="bg-gray-600 relative grow overflow-hidden rounded-full h-1.5 w-full">
<SliderPrimitive.Range className="bg-[#38bdac] absolute h-full rounded-full" />
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
key={index}
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
className="block size-4 shrink-0 rounded-full border-2 border-[#38bdac] bg-white shadow-sm focus-visible:ring-2 focus-visible:ring-[#38bdac] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>

View File

@@ -0,0 +1,93 @@
/**
* API 接口文档页 - 解决 /api-doc 404
* 内容与 开发文档/5、接口/API接口完整文档.md 保持一致
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Link2 } from 'lucide-react'
export function ApiDocPage() {
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="flex items-center gap-2 mb-8">
<Link2 className="w-8 h-8 text-[#38bdac]" />
<h1 className="text-2xl font-bold text-white">API </h1>
</div>
<p className="text-gray-400 mb-6">
API RESTful · v1.0 · /api ·
</p>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">1. </CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<p className="text-gray-400 mb-2"></p>
<ul className="space-y-1 text-gray-300 font-mono">
<li>/api/book </li>
<li>/api/payment </li>
<li>/api/referral </li>
<li>/api/user </li>
<li>/api/match </li>
<li>/api/admin ///</li>
<li>/api/config </li>
</ul>
</div>
<div>
<p className="text-gray-400 mb-2"></p>
<p className="text-gray-300">Cookie session_id</p>
<p className="text-gray-300">Authorization: Bearer admin-token-secret</p>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>GET /api/book/all-chapters </p>
<p>GET /api/book/chapter/:id </p>
<p>POST /api/book/sync </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">3. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>POST /api/payment/create-order </p>
<p>POST /api/payment/alipay/notify </p>
<p>POST /api/payment/wechat/notify </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">4. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>/api/referral/* </p>
<p>/api/user/* </p>
<p>/api/match/* </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">5. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>GET/POST /api/admin/referral-settings 广/ VIP </p>
<p>GET /api/db/users/api/db/book </p>
<p>GET /api/orders </p>
</CardContent>
</Card>
<p className="text-gray-500 text-xs">
/5/API接口完整文档.md
</p>
</div>
)
}

View File

@@ -26,7 +26,6 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import {
FileText,
BookOpen,
Settings2,
ChevronRight,
@@ -36,15 +35,13 @@ import {
X,
RefreshCw,
Link2,
Download,
Upload,
Eye,
Database,
Plus,
Image as ImageIcon,
Search,
Trash2,
} from 'lucide-react'
import { get, post, put } from '@/api/client'
import { get, put, del } from '@/api/client'
import { apiUrl } from '@/api/client'
interface SectionListItem {
@@ -119,69 +116,18 @@ function buildTree(sections: SectionListItem[]): Part[] {
}))
}
function parseTxtToJson(content: string, fileName: string): { id: string; title: string; price: number; content?: string; isFree?: boolean }[] {
const lines = content.split('\n')
const sections: { id: string; title: string; price: number; content?: string; isFree?: boolean }[] = []
let currentSection: { id: string; title: string; price: number; content?: string; isFree?: boolean } | null = null
let currentContent: string[] = []
let sectionIndex = 1
for (const line of lines) {
const titleMatch = line.match(/^#+\s+(.+)$/) || line.match(/^(\d+[.\、]\s*.+)$/)
if (titleMatch) {
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) sections.push(currentSection)
}
currentSection = {
id: `import-${sectionIndex}`,
title: titleMatch[1].replace(/^#+\s*/, '').trim(),
price: 1,
isFree: sectionIndex <= 3,
}
currentContent = []
sectionIndex++
} else if (currentSection) {
currentContent.push(line)
} else if (line.trim()) {
currentSection = {
id: `import-${sectionIndex}`,
title: fileName.replace(/\.(txt|md|markdown)$/i, ''),
price: 1,
isFree: true,
}
currentContent.push(line)
sectionIndex++
}
}
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) sections.push(currentSection)
}
return sections
}
export function ContentPage() {
const [sectionsList, setSectionsList] = useState<SectionListItem[]>([])
const [loading, setLoading] = useState(true)
const [expandedParts, setExpandedParts] = useState<string[]>([])
const [editingSection, setEditingSection] = useState<EditingSection | null>(null)
const [isSyncing, setIsSyncing] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isInitializing, setIsInitializing] = useState(false)
const [feishuDocUrl, setFeishuDocUrl] = useState('')
const [showFeishuModal, setShowFeishuModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [showNewSectionModal, setShowNewSectionModal] = useState(false)
const [importData, setImportData] = useState('')
const [isLoadingContent, setIsLoadingContent] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<{ id: string; title: string; price?: number; snippet?: string; partTitle?: string; chapterTitle?: string; matchType?: string }[]>([])
const [isSearching, setIsSearching] = useState(false)
const [uploadingImage, setUploadingImage] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const [newSection, setNewSection] = useState({
@@ -230,6 +176,24 @@ export function ContentPage() {
)
}
const handleDeleteSection = async (section: Section & { filePath?: string }) => {
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
try {
const res = await del<{ success?: boolean; error?: string }>(
`/api/db/book?id=${encodeURIComponent(section.id)}`,
)
if (res && (res as { success?: boolean }).success !== false) {
alert('已删除')
loadList()
} else {
alert('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('删除失败')
}
}
const handleReadSection = async (section: Section & { filePath?: string }) => {
setIsLoadingContent(true)
try {
@@ -396,121 +360,6 @@ export function ContentPage() {
}
}
const handleSyncToDatabase = async () => {
setIsSyncing(true)
try {
const res = await post<{ success?: boolean; message?: string; error?: string }>('/api/db/book', { action: 'sync' })
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { message?: string }).message || '同步成功')
loadList()
} else {
alert('同步失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('同步失败')
} finally {
setIsSyncing(false)
}
}
const handleExport = async () => {
setIsExporting(true)
try {
const res = await fetch(apiUrl('/api/db/book?action=export'), { credentials: 'include' })
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `book_sections_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
alert('导出成功')
} catch (e) {
console.error(e)
alert('导出失败')
} finally {
setIsExporting(false)
}
}
const handleImport = async () => {
if (!importData.trim()) {
alert('请输入或上传JSON数据')
return
}
setIsImporting(true)
try {
const data = JSON.parse(importData)
const res = await post<{ success?: boolean; message?: string; error?: string }>('/api/db/book', { action: 'import', data })
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { message?: string }).message || '导入成功')
setShowImportModal(false)
setImportData('')
loadList()
} else {
alert('导入失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('导入失败: JSON格式错误')
} finally {
setIsImporting(false)
}
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const content = (event.target?.result as string) || ''
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.json')) {
setImportData(content)
} else if (fileName.endsWith('.txt') || fileName.endsWith('.md') || fileName.endsWith('.markdown')) {
setImportData(JSON.stringify(parseTxtToJson(content, file.name), null, 2))
} else {
setImportData(content)
}
}
reader.readAsText(file)
}
const handleInitDatabase = async () => {
if (!confirm('确定要初始化数据库吗?这将创建所有必需的表结构。')) return
setIsInitializing(true)
try {
const res = await post<{ success?: boolean; data?: { message?: string }; error?: string }>('/api/db/init', {
adminToken: 'init_db_2025',
})
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { data?: { message?: string } }).data?.message || '初始化成功')
} else {
alert('初始化失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('初始化失败')
} finally {
setIsInitializing(false)
}
}
const handleSyncFeishu = async () => {
if (!feishuDocUrl.trim()) {
alert('请输入飞书文档链接')
return
}
setIsSyncing(true)
await new Promise((r) => setTimeout(r, 2000))
setIsSyncing(false)
setShowFeishuModal(false)
alert('飞书文档同步成功!')
}
const currentPart = tree.find((p) => p.id === newSection.partId)
const chaptersForPart = currentPart?.chapters ?? []
@@ -523,173 +372,19 @@ export function ContentPage() {
</div>
<div className="flex gap-2">
<Button
onClick={handleInitDatabase}
disabled={isInitializing}
onClick={() => {
const url = import.meta.env.VITE_API_DOC_URL || (typeof window !== 'undefined' ? `${window.location.origin}/api-doc` : '')
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Database className="w-4 h-4 mr-2" />
{isInitializing ? '初始化中...' : '初始化数据库'}
</Button>
<Button
onClick={handleSyncToDatabase}
disabled={isSyncing}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
</Button>
<Button
onClick={() => setShowImportModal(true)}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleExport}
disabled={isExporting}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? '导出中...' : '导出'}
</Button>
<Button onClick={() => setShowFeishuModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<FileText className="w-4 h-4 mr-2" />
<Link2 className="w-4 h-4 mr-2" />
API
</Button>
</div>
</div>
{/* 导入弹窗 */}
<Dialog open={showImportModal} onOpenChange={setShowImportModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Upload className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"> ( JSON / TXT / MD)</Label>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt,.md,.markdown"
onChange={handleFileUpload}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="w-full border-dashed border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
(JSON/TXT/MD)
</Button>
<p className="text-xs text-gray-500">
JSON格式: 直接导入章节数据<br />
TXT/MD格式: 自动解析为章节内容
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[200px] font-mono text-sm placeholder:text-gray-500"
placeholder={'JSON格式: [{"id": "1-1", "title": "章节标题", "content": "内容..."}]\n\n或直接粘贴TXT/MD内容系统将自动解析'}
value={importData}
onChange={(e) => setImportData(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => { setShowImportModal(false); setImportData('') }}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleImport}
disabled={isImporting || !importData.trim()}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 飞书同步弹窗 */}
<Dialog open={showFeishuModal} onOpenChange={setShowFeishuModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Link2 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
placeholder="https://xxx.feishu.cn/docx/..."
value={feishuDocUrl}
onChange={(e) => setFeishuDocUrl(e.target.value)}
/>
<p className="text-xs text-gray-500">访</p>
</div>
<div className="bg-[#38bdac]/10 border border-[#38bdac]/30 rounded-lg p-3">
<p className="text-[#38bdac] text-sm">
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowFeishuModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSyncFeishu}
disabled={isSyncing}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSyncing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 新建章节弹窗 */}
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto" showCloseButton>
@@ -1036,6 +731,15 @@ export function ContentPage() {
<Edit3 className="w-4 h-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSection(section)}
className="text-gray-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
))}