Files
Navi/frontend/src/App.jsx
T
小龙 d088cb4adf feat: 重构分类管理功能
- 添加独立分类管理弹窗
- 支持添加、编辑、删除、排序分类
- 10种预设颜色供选择
- 23种预设图标供网站选择
2026-04-06 17:32:00 +00:00

402 lines
24 KiB
React
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 React, { useState, useEffect } from 'react'
import { categoryApi, websiteApi } from './api'
// 预设图标
const PRESET_ICONS = [
'book', 'briefcase', 'code', 'dollar-sign', 'edit', 'folder', 'globe', 'heart',
'home', 'layers', 'link', 'map-pin', 'message-circle', 'monitor', 'music',
'package', 'settings', 'shopping-cart', 'star', 'tool', 'truck', 'video', 'zap'
]
// 预设颜色
const PRESET_COLORS = [
'#667eea', '#764ba2', '#f56565', '#ed8936', '#ecc94b',
'#48bb78', '#38b2ac', '#4299e1', '#9f7aea', '#ed64a6'
]
// SVG 图标
const Icon = ({ name, size = 16 }) => {
const icons = {
bookmark: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>,
search: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>,
plus: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
folder: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>,
star: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>,
globe: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/></svg>,
edit: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
trash: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>,
x: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
grip: <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/></svg>,
'book': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>,
'briefcase': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>,
'code': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
'dollar-sign': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>,
'heart': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>,
'home': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>,
'layers': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>,
'link': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>,
'map-pin': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>,
'message-circle': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>,
'monitor': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
'music': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>,
'package': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,
'settings': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0 1.82.33l.06.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09a1.65 1.65 0 0 0 1-1.51 1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1-1.51 1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1.51-1H3a2 2 0 0 1 2-2 2 2 0 0 1-2 2h.09a1.65 1.65 0 0 0 1 1.51 1H3a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2 2 2 0 0 1 2 2h.09a1.65 1.65 0 0 0 1.51 1H3a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2 2 2 0 0 1 2 2h.09a1.65 1.65 0 0 0 1.51 1H3a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1H3a2 2 0 0 1-2 2"/></svg>,
'shopping-cart': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>,
'tool': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
'truck': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="1" y="3" width="15" height="13"/><polygon points="16 8 20 8 20 16 16 16 16 8"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>,
'video': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="23 7 16 12 23 5 23 19 16 19 16 12 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>,
'zap': <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>,
}
return icons[name] || icons.globe
}
function App() {
const [categories, setCategories] = useState([])
const [websites, setWebsites] = useState([])
const [selectedCategory, setSelectedCategory] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
const [modal, setModal] = useState({ show: false, type: 'website', data: null })
const [categoryModal, setCategoryModal] = useState(false)
const [categoryForm, setCategoryForm] = useState({ name: '', icon: 'folder', color: '#667eea', sort_order: 0 })
const [editingCategory, setEditingCategory] = useState(null)
const [loading, setLoading] = useState(true)
const [websiteForm, setWebsiteForm] = useState({ name: '', url: '', icon: '', description: '', category_id: '', is_favorite: false })
useEffect(() => { loadData() }, [])
const loadData = async () => {
setLoading(true)
try {
const [c, w] = await Promise.all([categoryApi.getAll(), websiteApi.getAll()])
setCategories(c.data || [])
setWebsites(w.data || [])
} catch (e) { console.error(e) }
setLoading(false)
}
// 过滤网站
const filteredWebsites = websites.filter(site => {
const matchCat = selectedCategory === null || site.category_id === selectedCategory
const query = searchQuery.toLowerCase().trim()
const matchSearch = !query ||
(site.name && site.name.toLowerCase().includes(query)) ||
(site.description && site.description.toLowerCase().includes(query))
return matchCat && matchSearch
})
const favorites = filteredWebsites.filter(s => s.is_favorite)
const normal = filteredWebsites.filter(s => !s.is_favorite)
const getCategoryCount = (catId) => websites.filter(w => w.category_id === catId).length
// 打开网站弹窗
const openWebsiteModal = (data = null) => {
setWebsiteForm(data ? {
name: data.name,
url: data.url,
icon: data.icon || '',
description: data.description || '',
category_id: data.category_id || '',
is_favorite: !!data.is_favorite
} : { name: '', url: '', icon: '', description: '', category_id: selectedCategory || '', is_favorite: false })
setModal({ show: true, type: 'website', data })
}
const closeModal = () => setModal({ ...modal, show: false, data: null })
// 打开分类管理弹窗
const openCategoryModal = () => setCategoryModal(true)
const closeCategoryModal = () => { setCategoryModal(false); setEditingCategory(null); setCategoryForm({ name: '', icon: 'folder', color: '#667eea', sort_order: 0 }) }
const editCategory = (cat) => {
setEditingCategory(cat)
setCategoryForm({ name: cat.name, icon: cat.icon || 'folder', color: cat.color || '#667eea', sort_order: cat.sort_order || 0 })
}
const saveCategory = async () => {
try {
const payload = {
name: categoryForm.name,
icon: categoryForm.icon,
color: categoryForm.color,
sort_order: parseInt(categoryForm.sort_order) || 0
}
if (editingCategory) {
await categoryApi.update(editingCategory.id, payload)
} else {
await categoryApi.create(payload)
}
setCategoryForm({ name: '', icon: 'folder', color: '#667eea', sort_order: 0 })
setEditingCategory(null)
loadData()
} catch (e) { console.error(e) }
}
const deleteCategory = async (id) => {
if (!confirm('确定删除该分类?该分类下的网站将变为未分类状态。')) return
try {
await categoryApi.delete(id)
if (selectedCategory === id) setSelectedCategory(null)
loadData()
} catch (e) {}
}
// 保存网站
const handleWebsiteSubmit = async () => {
try {
const payload = { ...websiteForm, category_id: websiteForm.category_id || null }
if (modal.data) {
await websiteApi.update(modal.data.id, payload)
} else {
await websiteApi.create(payload)
}
closeModal()
loadData()
} catch (e) {}
}
const handleDeleteWebsite = async (id) => {
if (!confirm('确定删除?')) return
try {
await websiteApi.delete(id)
loadData()
} catch (e) {}
}
const toggleFavorite = async (site) => {
try {
await websiteApi.update(site.id, { is_favorite: !site.is_favorite })
loadData()
} catch (e) {}
}
const handleClick = async (site) => {
try { await websiteApi.click(site.id) } catch (e) {}
window.open(site.url, '_blank')
}
if (loading) return <div className="loading"><div className="spinner"/></div>
return (
<div className="app">
{/* 左侧边栏 */}
<aside className="sidebar">
<div className="sidebar-header">
<div className="logo">
<div className="logo-icon"><Icon name="bookmark"/></div>
<span className="logo-text">EasyNavi</span>
</div>
</div>
<div className="categories-section">
<div className="categories-title">分类</div>
<div className={`category-item ${selectedCategory === null ? 'active' : ''}`} onClick={() => setSelectedCategory(null)}>
<div className="category-icon"><Icon name="globe" size={14}/></div>
<span className="category-name">全部网站</span>
<span className="category-count">{websites.length}</span>
</div>
{categories.map(cat => (
<div key={cat.id} className="category-item-wrapper">
<div className={`category-item ${selectedCategory === cat.id ? 'active' : ''}`} onClick={() => setSelectedCategory(cat.id)}>
<div className="category-icon"><Icon name={cat.icon || 'folder'} size={14}/></div>
<span className="category-name">{cat.name}</span>
<span className="category-count">{getCategoryCount(cat.id)}</span>
</div>
<button className="category-edit-btn" onClick={(e) => { e.stopPropagation(); editCategory(cat) }} title="编辑">
<Icon name="edit" size={12}/>
</button>
</div>
))}
</div>
<div className="sidebar-footer">
<button className="add-btn" onClick={openCategoryModal}>
<Icon name="folder" size={14}/><span>添加分类</span>
</button>
<button className="add-btn" onClick={() => openWebsiteModal()}>
<Icon name="plus" size={14}/><span>添加网站</span>
</button>
</div>
</aside>
{/* 主内容 */}
<main className="main-content">
<div className="search-bar">
<Icon name="search" size={18} className="search-icon"/>
<input className="search-input" placeholder="搜索网站名称或描述..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}/>
</div>
{favorites.length > 0 && (
<div style={{ marginBottom: 36 }}>
<div className="section-header">
<div className="section-title"><Icon name="star" size={18}/> 我的收藏 <span className="section-count">({favorites.length})</span></div>
</div>
<div className="website-grid">
{favorites.map(site => (
<SiteCard key={site.id} site={site} onClick={() => handleClick(site)} onToggleFav={() => toggleFavorite(site)} onEdit={() => openWebsiteModal(site)} onDelete={() => handleDeleteWebsite(site.id)}/>
))}
</div>
</div>
)}
{normal.length > 0 && (
<div style={{ marginBottom: 36 }}>
<div className="section-header">
<div className="section-title">
{selectedCategory ? categories.find(c => c.id === selectedCategory)?.name : '全部网站'}
<span className="section-count">({normal.length})</span>
</div>
</div>
<div className="website-grid">
{normal.map(site => (
<SiteCard key={site.id} site={site} onClick={() => handleClick(site)} onToggleFav={() => toggleFavorite(site)} onEdit={() => openWebsiteModal(site)} onDelete={() => handleDeleteWebsite(site.id)}/>
))}
</div>
</div>
)}
{filteredWebsites.length === 0 && (
<div className="empty-state">
<div className="empty-icon"><Icon name="globe" size={44}/></div>
<div className="empty-title">{searchQuery ? '没有找到匹配的网站' : '暂无网站'}</div>
<div className="empty-desc">{searchQuery ? '尝试其他关键词' : '点击左侧「添加网站」开始收藏'}</div>
{!searchQuery && <button className="add-btn" onClick={() => openWebsiteModal()}><Icon name="plus" size={14}/><span>添加网站</span></button>}
</div>
)}
</main>
{/* 网站弹窗 */}
{modal.show && (
<div className="modal-overlay" onClick={closeModal}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">{modal.data ? '编辑网站' : '添加网站'}</div>
<button className="modal-close" onClick={closeModal}><Icon name="x" size={16}/></button>
</div>
<div className="form-group">
<label className="form-label">名称 *</label>
<input className="form-input" value={websiteForm.name} onChange={e => setWebsiteForm({...websiteForm, name: e.target.value})} placeholder="网站名称"/>
</div>
<div className="form-group">
<label className="form-label">链接 *</label>
<input className="form-input" value={websiteForm.url} onChange={e => setWebsiteForm({...websiteForm, url: e.target.value})} placeholder="https://..."/>
</div>
<div className="form-group">
<label className="form-label">图标可选</label>
<div className="icon-picker">
<button type="button" className={`icon-option ${!websiteForm.icon ? 'selected' : ''}`} onClick={() => setWebsiteForm({...websiteForm, icon: ''})}>
<Icon name="globe" size={16}/>
</button>
{PRESET_ICONS.map(iconName => (
<button key={iconName} type="button" className={`icon-option ${websiteForm.icon === iconName ? 'selected' : ''}`} onClick={() => setWebsiteForm({...websiteForm, icon: iconName})}>
<Icon name={iconName} size={16}/>
</button>
))}
</div>
</div>
<div className="form-group">
<label className="form-label">描述</label>
<input className="form-input" value={websiteForm.description || ''} onChange={e => setWebsiteForm({...websiteForm, description: e.target.value})} placeholder="简短描述"/>
</div>
<div className="form-group">
<label className="form-label">分类</label>
<select className="form-select" value={websiteForm.category_id} onChange={e => setWebsiteForm({...websiteForm, category_id: e.target.value})}>
<option value="">未分类</option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label className="form-checkbox">
<input type="checkbox" checked={websiteForm.is_favorite} onChange={e => setWebsiteForm({...websiteForm, is_favorite: e.target.checked})}/>
收藏
</label>
</div>
<div className="form-actions">
<button className="btn btn-cancel" onClick={closeModal}>取消</button>
<button className="btn btn-submit" onClick={handleWebsiteSubmit}>{modal.data ? '保存' : '添加'}</button>
</div>
</div>
</div>
)}
{/* 分类管理弹窗 */}
{categoryModal && (
<div className="modal-overlay" onClick={closeCategoryModal}>
<div className="modal" style={{maxWidth: '500px'}} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">分类管理</div>
<button className="modal-close" onClick={closeCategoryModal}><Icon name="x" size={16}/></button>
</div>
{/* 添加/编辑表单 */}
<div className="category-form">
<div className="form-row">
<input className="form-input" style={{flex: 1}} value={categoryForm.name} onChange={e => setCategoryForm({...categoryForm, name: e.target.value})} placeholder="分类名称"/>
<input type="number" className="form-input" style={{width: '80px'}} value={categoryForm.sort_order} onChange={e => setCategoryForm({...categoryForm, sort_order: e.target.value})} placeholder="排序"/>
</div>
<div className="form-group">
<label className="form-label">颜色</label>
<div className="color-picker-row">
{PRESET_COLORS.map(c => (
<button key={c} type="button" className={`color-option ${categoryForm.color === c ? 'selected' : ''}`} style={{background: c}} onClick={() => setCategoryForm({...categoryForm, color: c})}/>
))}
</div>
</div>
<button className="btn btn-submit" style={{width: '100%', marginTop: '12px'}} onClick={saveCategory} disabled={!categoryForm.name}>
{editingCategory ? '保存修改' : '添加分类'}
</button>
</div>
{/* 分类列表 */}
<div className="category-list">
{categories.map(cat => (
<div key={cat.id} className="category-list-item">
<div className="category-list-info">
<span className="category-list-color" style={{background: cat.color || '#667eea'}}/>
<Icon name={cat.icon || 'folder'} size={14}/>
<span className="category-list-name">{cat.name}</span>
<span className="category-list-count">({getCategoryCount(cat.id)})</span>
</div>
<div className="category-list-actions">
<button onClick={() => editCategory(cat)}><Icon name="edit" size={14}/></button>
<button onClick={() => deleteCategory(cat.id)}><Icon name="trash" size={14}/></button>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)
}
function SiteCard({ site, onClick, onToggleFav, onEdit, onDelete }) {
return (
<div className="site-card" onClick={onClick}>
{site.category && <span className="card-category">{site.category.name}</span>}
<div className="card-icon">
{site.icon && site.icon.startsWith('http') ? <img src={site.icon} alt=""/> : <Icon name={site.icon || 'globe'} size={26}/>}
</div>
<div className="card-name">{site.name}</div>
{site.description && <div className="card-desc">{site.description}</div>}
<div className="card-bottom-actions">
<button className={`card-favorite ${site.is_favorite ? 'active' : ''}`} onClick={(e) => { e.stopPropagation(); onToggleFav() }}>
<Icon name="star" size={15}/>
</button>
<button className="action-btn" onClick={(e) => { e.stopPropagation(); onEdit() }}>
<Icon name="edit" size={13}/>
</button>
<button className="action-btn delete" onClick={(e) => { e.stopPropagation(); onDelete() }}>
<Icon name="trash" size={13}/>
</button>
</div>
</div>
)
}
export default App