Import source: app/src/admin/UserManagement
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,385 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useToast, AuthorAvatar, RoleBadge, ConfirmModal, EmptyState, inputStyle, pillBtn } from '../../teknav-ui.jsx';
|
||||||
|
import { adminApi } from '../lib/admin-api.js';
|
||||||
|
import { BRAND, PanelState, actionBtn, ADMIN_ROLE_NAMES } from './Shared.jsx';
|
||||||
|
|
||||||
|
function UserManagement() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
return adminApi.listUsers()
|
||||||
|
.then(items => setUsers(items ?? []))
|
||||||
|
.catch(() => { setUsers([]); setError(true); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let list = users;
|
||||||
|
if (search) { const q = search.toLowerCase(); list = list.filter(u => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)); }
|
||||||
|
if (roleFilter) list = list.filter(u => u.role === roleFilter);
|
||||||
|
return list;
|
||||||
|
}, [users, search, roleFilter]);
|
||||||
|
|
||||||
|
const toggleStatus = async (id, status) => {
|
||||||
|
try {
|
||||||
|
await adminApi.updateUser(id, { status: status === 'active' ? 'suspended' : 'active' });
|
||||||
|
toast('وضعیت کاربر تغییر کرد');
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در تغییر وضعیت کاربر', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const changeRole = async (id, role) => {
|
||||||
|
try {
|
||||||
|
await adminApi.updateUser(id, { role });
|
||||||
|
toast('نقش کاربر تغییر کرد');
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در تغییر نقش کاربر', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{selectedUser && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, zIndex: 500, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div onClick={() => setSelectedUser(null)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.45)' }} />
|
||||||
|
<div style={{ position: 'relative', background: '#FAF7F0', borderRadius: 14, padding: 32, maxWidth: 420, width: '90%', direction: 'rtl', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
|
||||||
|
<h3 style={{ margin: '0 0 20px', color: '#263238' }}>جزئیات کاربر</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginBottom: 20 }}>
|
||||||
|
<AuthorAvatar author={{ initials: selectedUser.name.slice(0, 2), color: '#0F6B73' }} size={60} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#263238' }}>{selectedUser.name}</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#5F6B6D', direction: 'ltr' }}>{selectedUser.email}</div>
|
||||||
|
<div style={{ marginTop: 6 }}><RoleBadge role={selectedUser.role} /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
|
||||||
|
{[['مقالهها', selectedUser.articleCount ?? '—'], ['تاریخ عضویت', selectedUser.joinDate], ['وضعیت', selectedUser.status === 'active' ? 'فعال' : 'غیرفعال']].map(([l, v]) => (
|
||||||
|
<div key={l} style={{ background: '#F4EFE6', borderRadius: 8, padding: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D' }}>{l}</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#263238' }}>{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedUser(null)} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', width: '100%', justifyContent: 'center' }}>بستن</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="جستجوی کاربر..." style={{ ...inputStyle, maxWidth: 240 }} dir="rtl" />
|
||||||
|
<select value={roleFilter} onChange={e => setRoleFilter(e.target.value)} style={{ ...inputStyle, maxWidth: 160 }} dir="rtl">
|
||||||
|
<option value="">همه نقشها</option>
|
||||||
|
{['admin', 'editor', 'writer', 'reviewer'].map(r => <option key={r} value={r}>{ADMIN_ROLE_NAMES[r]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'Vazirmatn,sans-serif', fontSize: 13, direction: 'rtl' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: '#F4EFE6', borderBottom: '1px solid #E4DDD2' }}>
|
||||||
|
{['نام', 'ایمیل', 'نقش', 'وضعیت', 'مقالهها', 'اقدام'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '12px 16px', textAlign: 'right', color: '#5F6B6D', fontWeight: 600 }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((u, i) => (
|
||||||
|
<tr key={u.id} style={{ borderBottom: '1px solid #E4DDD2', background: i % 2 === 0 ? '#fff' : '#FDFBF7' }}>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<AuthorAvatar author={{ initials: u.name.slice(0, 2), color: '#0F6B73' }} size={32} />
|
||||||
|
<span style={{ fontWeight: 600, color: '#263238' }}>{u.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', direction: 'ltr', color: '#5F6B6D', fontSize: 12 }}>{u.email}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<select value={u.role} onChange={e => changeRole(u.id, e.target.value)} style={{ border: '1px solid #E4DDD2', borderRadius: 6, padding: '3px 8px', fontSize: 12, fontFamily: 'Vazirmatn,sans-serif', background: '#fff' }} dir="rtl">
|
||||||
|
{['admin', 'editor', 'writer', 'reviewer'].map(r => <option key={r} value={r}>{ADMIN_ROLE_NAMES[r]}</option>)}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: u.status === 'active' ? '#2F8F6B' : '#C94C4C' }}>{u.status === 'active' ? 'فعال' : 'غیرفعال'}</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 16px', color: '#5F6B6D' }}>{u.articleCount ?? '—'}</td>
|
||||||
|
<td style={{ padding: '12px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button onClick={() => setSelectedUser(u)} style={{ ...actionBtn, background: '#0F6B7318', color: '#0F6B73' }}>جزئیات</button>
|
||||||
|
<button onClick={() => toggleStatus(u.id, u.status)} style={{ ...actionBtn, background: u.status === 'active' ? '#C94C4C18' : '#2F8F6B18', color: u.status === 'active' ? '#C94C4C' : '#2F8F6B' }}>
|
||||||
|
{u.status === 'active' ? 'غیرفعال' : 'فعال'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<PanelState loading={loading} error={error} empty={!loading && !error && users.length === 0} emptyTitle="هنوز کاربری از API دریافت نشده است" />
|
||||||
|
{!loading && !error && users.length > 0 && filtered.length === 0 && <EmptyState title="موردی مطابق فیلترها یافت نشد" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryManagement() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [cats, setCats] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', description: '', color: '#0F6B73' });
|
||||||
|
const [confirm, setConfirm] = useState(null);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
return adminApi.listCategories()
|
||||||
|
.then(items => setCats(items ?? []))
|
||||||
|
.catch(() => { setCats([]); setError(true); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
const addCat = async () => {
|
||||||
|
if (!form.name.trim()) { toast('نام دستهبندی الزامی است', 'error'); return; }
|
||||||
|
try {
|
||||||
|
await adminApi.createCategory({ ...form, slug: form.name.replace(/\s+/g, '-'), diagram: 'neural' });
|
||||||
|
toast('دستهبندی اضافه شد'); setForm({ name: '', description: '', color: '#0F6B73' }); refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در افزودن دستهبندی', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const del = async (id) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteCategory(id);
|
||||||
|
toast('دستهبندی حذف شد'); refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در حذف دستهبندی', 'error');
|
||||||
|
}
|
||||||
|
setConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{confirm && <ConfirmModal msg="آیا از حذف این دستهبندی اطمینان دارید؟" onConfirm={() => del(confirm)} onCancel={() => setConfirm(null)} />}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24, marginBottom: 24 }}>
|
||||||
|
<h3 style={{ margin: '0 0 16px', fontSize: 15, color: '#263238', direction: 'rtl' }}>افزودن دستهبندی جدید</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', direction: 'rtl' }}>
|
||||||
|
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="نام دستهبندی" style={{ ...inputStyle, maxWidth: 200 }} dir="rtl" />
|
||||||
|
<input value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} placeholder="توضیحات" style={{ ...inputStyle, flex: 1, minWidth: 200 }} dir="rtl" />
|
||||||
|
<input type="color" value={form.color} onChange={e => setForm(f => ({ ...f, color: e.target.value }))} style={{ width: 48, height: 40, border: '1px solid #E4DDD2', borderRadius: 8, cursor: 'pointer', padding: 2 }} />
|
||||||
|
<button onClick={addCat} style={{ ...pillBtn, background: '#0F6B73', color: '#fff' }}>افزودن</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(240px,1fr))', gap: 14 }}>
|
||||||
|
{cats.map(c => (
|
||||||
|
<div key={c.id} style={{ background: '#fff', border: `1px solid ${c.color}30`, borderRadius: 10, padding: '16px 18px', borderRight: `4px solid ${c.color}`, direction: 'rtl', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#263238' }}>{c.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D', marginTop: 2 }}>{c.articleCount} مقاله</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setConfirm(c.dbId || c.id)} style={{ ...actionBtn, background: '#C94C4C18', color: '#C94C4C' }}>حذف</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<PanelState loading={loading} error={error} empty={!loading && !error && cats.length === 0} emptyTitle="هنوز دستهبندیای در بکاند ثبت نشده است" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagsManagement() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [newTag, setNewTag] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
return adminApi.listTags()
|
||||||
|
.then(items => setTags(items ?? []))
|
||||||
|
.catch(() => { setTags([]); setError(true); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
const addTag = async () => {
|
||||||
|
if (!newTag.trim()) return;
|
||||||
|
try {
|
||||||
|
await adminApi.createTag(newTag);
|
||||||
|
toast('برچسب اضافه شد'); setNewTag(''); refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در افزودن برچسب', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const del = async (id) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteTag(id);
|
||||||
|
toast('برچسب حذف شد'); refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در حذف برچسب', 'error');
|
||||||
|
}
|
||||||
|
setConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = search ? tags.filter(t => t.name.includes(search)) : tags;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{confirm && <ConfirmModal msg="آیا از حذف این برچسب اطمینان دارید؟" onConfirm={() => del(confirm)} onCancel={() => setConfirm(null)} />}
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginBottom: 24, direction: 'rtl', flexWrap: 'wrap' }}>
|
||||||
|
<input value={newTag} onChange={e => setNewTag(e.target.value)} onKeyDown={e => e.key === 'Enter' && addTag()} placeholder="برچسب جدید..." style={{ ...inputStyle, maxWidth: 220 }} dir="rtl" />
|
||||||
|
<button onClick={addTag} style={{ ...pillBtn, background: '#0F6B73', color: '#fff' }}>افزودن</button>
|
||||||
|
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="جستجوی برچسب..." style={{ ...inputStyle, maxWidth: 200 }} dir="rtl" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{filtered.map(t => (
|
||||||
|
<div key={t.id} style={{ background: '#F4EFE6', border: '1px solid #E4DDD2', borderRadius: 20, padding: '5px 14px', display: 'flex', alignItems: 'center', gap: 8, direction: 'rtl' }}>
|
||||||
|
<span style={{ fontSize: 13, color: '#263238' }}>{t.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#5F6B6D' }}>({t.count})</span>
|
||||||
|
<button onClick={() => setConfirm(t.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#C94C4C', fontSize: 14, padding: 0 }}>✕</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<PanelState loading={loading} error={error} empty={!loading && !error && tags.length === 0} emptyTitle="هنوز برچسبی در بکاند ثبت نشده است" />
|
||||||
|
{!loading && !error && tags.length > 0 && filtered.length === 0 && <EmptyState title="موردی مطابق جستجو یافت نشد" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorsManagement() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [authors, setAuthors] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [form, setForm] = useState({ name: '', slug: '', specialty: '', bio: '', initials: '', color: '#0F6B73', social: {} });
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [confirm, setConfirm] = useState(null);
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
return adminApi.listAuthors()
|
||||||
|
.then(items => setAuthors(items ?? []))
|
||||||
|
.catch(() => { setAuthors([]); setError(true); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!form.name.trim() || !form.slug.trim() || !form.specialty.trim()) {
|
||||||
|
toast('نام، نام مستعار و تخصص الزامی است', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await adminApi.updateAuthor(editing, form);
|
||||||
|
toast('نویسنده ویرایش شد');
|
||||||
|
} else {
|
||||||
|
await adminApi.createAuthor(form);
|
||||||
|
toast('نویسنده اضافه شد');
|
||||||
|
}
|
||||||
|
setForm({ name: '', slug: '', specialty: '', bio: '', initials: '', color: '#0F6B73', social: {} });
|
||||||
|
setEditing(null);
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در ذخیره نویسنده', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async (id) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deleteAuthor(id);
|
||||||
|
toast('نویسنده حذف شد');
|
||||||
|
refresh();
|
||||||
|
} catch {
|
||||||
|
toast('خطا در حذف نویسنده', 'error');
|
||||||
|
}
|
||||||
|
setConfirm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const edit = (author) => {
|
||||||
|
setForm({
|
||||||
|
name: author.name,
|
||||||
|
slug: author.slug,
|
||||||
|
specialty: author.specialty,
|
||||||
|
bio: author.bio || '',
|
||||||
|
initials: author.initials || '',
|
||||||
|
color: author.color || '#0F6B73',
|
||||||
|
social: author.social || {},
|
||||||
|
});
|
||||||
|
setEditing(author.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
setForm({ name: '', slug: '', specialty: '', bio: '', initials: '', color: '#0F6B73', social: {} });
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{confirm && <ConfirmModal msg="آیا از حذف این نویسنده اطمینان دارید؟" onConfirm={() => del(confirm)} onCancel={() => setConfirm(null)} />}
|
||||||
|
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24, marginBottom: 24 }}>
|
||||||
|
<h3 style={{ margin: '0 0 16px', fontSize: 15, color: '#263238', direction: 'rtl' }}>
|
||||||
|
{editing ? 'ویرایش نویسنده' : 'افزودن نویسنده جدید'}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12, direction: 'rtl' }}>
|
||||||
|
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="نام نویسنده *" style={inputStyle} dir="rtl" />
|
||||||
|
<input value={form.slug} onChange={e => setForm(f => ({ ...f, slug: e.target.value }))} placeholder="نام مستعار (slug) *" style={inputStyle} dir="rtl" />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12, direction: 'rtl' }}>
|
||||||
|
<input value={form.specialty} onChange={e => setForm(f => ({ ...f, specialty: e.target.value }))} placeholder="تخصص *" style={inputStyle} dir="rtl" />
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input value={form.initials} onChange={e => setForm(f => ({ ...f, initials: e.target.value }))} placeholder="حروف اول" style={{ ...inputStyle, flex: 1 }} dir="rtl" maxLength={3} />
|
||||||
|
<input type="color" value={form.color} onChange={e => setForm(f => ({ ...f, color: e.target.value }))} style={{ width: 48, height: 40, border: '1px solid #E4DDD2', borderRadius: 8, cursor: 'pointer', padding: 2 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea value={form.bio} onChange={e => setForm(f => ({ ...f, bio: e.target.value }))} placeholder="بیوگرافی" style={{ ...inputStyle, minHeight: 80, resize: 'vertical', fontFamily: 'Vazirmatn,sans-serif' }} dir="rtl" />
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
|
||||||
|
<button onClick={save} style={{ ...pillBtn, background: '#0F6B73', color: '#fff' }}>
|
||||||
|
{editing ? 'ذخیره تغییرات' : 'افزودن'}
|
||||||
|
</button>
|
||||||
|
{editing && <button onClick={cancel} style={{ ...pillBtn, background: '#5F6B6D18', color: '#5F6B6D' }}>انصراف</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(320px,1fr))', gap: 16 }}>
|
||||||
|
{authors.map(a => (
|
||||||
|
<div key={a.id} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20, direction: 'rtl', borderRight: `4px solid ${a.color}` }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
|
<div style={{ width: 48, height: 48, borderRadius: '50%', background: `${a.color}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700, color: a.color }}>
|
||||||
|
{a.initials || a.name.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: '#263238' }}>{a.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D', marginTop: 2 }}>{a.specialty}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{a.bio && <div style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 12, lineHeight: 1.6 }}>{a.bio}</div>}
|
||||||
|
<div style={{ fontSize: 11, color: '#5F6B6D', marginBottom: 12 }}>
|
||||||
|
{a.articleCount || 0} مقاله • slug: {a.slug}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => edit(a)} style={{ ...actionBtn, background: '#0F6B7318', color: '#0F6B73' }}>ویرایش</button>
|
||||||
|
<button onClick={() => setConfirm(a.id)} style={{ ...actionBtn, background: '#C94C4C18', color: '#C94C4C' }}>حذف</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<PanelState loading={loading} error={error} empty={!loading && !error && authors.length === 0} emptyTitle="هنوز نویسندهای در بکاند ثبت نشده است" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { UserManagement, CategoryManagement, TagsManagement, AuthorsManagement };
|
||||||
Reference in New Issue
Block a user