feat(admin): add taxonomy and authorization pages

This commit is contained in:
2026-06-12 15:08:31 +03:30
parent bced5dceb1
commit 9051e32e5a
8 changed files with 877 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
import AdminAuthorizations from "@/views/AdminAuthorizations";
export default function AdminAuthorizationsPage() {
return <AdminAuthorizations />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlogCategories from "@/views/AdminBlogCategories";
export default function AdminBlogCategoriesPage() {
return <AdminBlogCategories />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlogTags from "@/views/AdminBlogTags";
export default function AdminBlogTagsPage() {
return <AdminBlogTags />;
}

View File

@@ -397,6 +397,21 @@ class ApiClient {
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
}
async listAuthorizationRoles() {
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
}
async getUserAuthorization(userId: number) {
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`);
}
async updateUserAuthorization(userId: number, data: Types.UserAuthorizationUpdateSchema) {
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// ============= Blog Endpoints =============
async getPosts(params?: {
@@ -648,6 +663,30 @@ class ApiClient {
return this.request<Types.CategorySchema[]>('/api/blog/categories');
}
async listAdminCategories() {
return this.request<Types.AdminCategorySchema[]>('/api/blog/admin/categories');
}
async createCategory(data: Types.CategoryWriteSchema) {
return this.request<Types.AdminCategorySchema>('/api/blog/admin/categories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCategory(categoryId: number, data: Types.CategoryWriteSchema) {
return this.request<Types.AdminCategorySchema>(`/api/blog/admin/categories/${categoryId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteCategory(categoryId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/categories/${categoryId}`, {
method: 'DELETE',
});
}
async getCategory(slug: string) {
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
}
@@ -667,6 +706,30 @@ class ApiClient {
return this.request<Types.TagSchema[]>('/api/blog/tags');
}
async listAdminTags() {
return this.request<Types.AdminTagSchema[]>('/api/blog/admin/tags');
}
async createTag(data: Types.TagWriteSchema) {
return this.request<Types.AdminTagSchema>('/api/blog/admin/tags', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateTag(tagId: number, data: Types.TagWriteSchema) {
return this.request<Types.AdminTagSchema>(`/api/blog/admin/tags/${tagId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteTag(tagId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/tags/${tagId}`, {
method: 'DELETE',
});
}
async getTag(slug: string) {
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
}

View File

@@ -68,6 +68,33 @@ export interface UserListSchema {
date_joined: string;
}
export interface AuthorizationRoleSchema {
key: string;
label: string;
description: string;
enabled: boolean;
locked: boolean;
}
export interface UserAuthorizationSchema {
id: number;
username: string;
email?: string | null;
mobile?: string | null;
first_name: string;
last_name: string;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
groups: string[];
roles: AuthorizationRoleSchema[];
}
export interface UserAuthorizationUpdateSchema {
is_staff: boolean;
groups: string[];
}
export interface UserRegistrationSchema {
mobile: string;
code: string;
@@ -403,6 +430,17 @@ export interface CategorySchema {
created_at: string;
}
export interface AdminCategorySchema extends CategorySchema {
post_count: number;
}
export interface CategoryWriteSchema {
name: string;
slug?: string | null;
description?: string | null;
parent_id?: number | null;
}
export interface TagSchema {
id: number;
name: string;
@@ -410,6 +448,15 @@ export interface TagSchema {
created_at: string;
}
export interface AdminTagSchema extends TagSchema {
post_count: number;
}
export interface TagWriteSchema {
name: string;
slug?: string | null;
}
export interface BlogFilterCategory {
id: number;
name: string;

View File

@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2, Search, ShieldCheck, UserCog } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { cn, resolveErrorMessage } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
const PAGE_SIZE = 25;
function fullName(user: Pick<Types.UserListSchema, "first_name" | "last_name" | "username">) {
return [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username;
}
export default function AdminAuthorizations() {
const { user } = useAuth();
const { toast } = useToast();
const [searchDraft, setSearchDraft] = useState("");
const [search, setSearch] = useState("");
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [draftAuth, setDraftAuth] = useState<Types.UserAuthorizationUpdateSchema | null>(null);
const [saving, setSaving] = useState(false);
const usersQuery = useQuery({
queryKey: ["admin", "authorizations", "users", search],
queryFn: () => api.listUsers({ search: search || undefined, limit: PAGE_SIZE, offset: 0 }),
});
const authQuery = useQuery({
queryKey: ["admin", "authorizations", selectedUserId],
queryFn: () => api.getUserAuthorization(selectedUserId as number),
enabled: Boolean(selectedUserId),
});
const selectedAuth = authQuery.data;
const isSelf = Boolean(selectedAuth && user?.id === selectedAuth.id);
const effectiveDraft = draftAuth ?? (selectedAuth ? {
is_staff: selectedAuth.is_staff,
groups: selectedAuth.groups.filter((group) => ["blog_editor", "blog_supervisor", "association_admin"].includes(group)),
} : null);
const selectUser = async (target: Types.UserListSchema) => {
setSelectedUserId(target.id);
setDraftAuth(null);
};
const toggleGroup = (group: string, checked: boolean) => {
if (!effectiveDraft || isSelf) return;
setDraftAuth({
...effectiveDraft,
groups: checked
? Array.from(new Set([...effectiveDraft.groups, group]))
: effectiveDraft.groups.filter((item) => item !== group),
});
};
const toggleStaff = (checked: boolean) => {
if (!effectiveDraft || isSelf) return;
setDraftAuth({ ...effectiveDraft, is_staff: checked });
};
const saveAuthorization = async () => {
if (!selectedUserId || !effectiveDraft || isSelf) return;
try {
setSaving(true);
const updated = await api.updateUserAuthorization(selectedUserId, effectiveDraft);
setDraftAuth({
is_staff: updated.is_staff,
groups: updated.groups.filter((group) => ["blog_editor", "blog_supervisor", "association_admin"].includes(group)),
});
toast({ title: "دسترسی کاربر به‌روزرسانی شد", variant: "success" });
await usersQuery.refetch();
await authQuery.refetch();
} catch (error) {
toast({
title: "ذخیره دسترسی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<div className="text-right">
<h2 className="text-2xl font-bold">مدیریت دسترسیها</h2>
<p className="mt-1 text-sm text-muted-foreground">تخصیص نقشهای امن و آماده به کاربران. مجوزهای مستقیم Django از این صفحه قابل تغییر نیستند.</p>
</div>
<div className="grid gap-6 xl:grid-cols-[380px_1fr]">
<Card className="h-fit">
<CardHeader className="text-right">
<CardTitle>جستجوی کاربر</CardTitle>
<CardDescription>نام، موبایل، ایمیل یا نام کاربری را جستجو کنید.</CardDescription>
</CardHeader>
<CardContent className="space-y-4" dir="ltr">
<div className="flex gap-2">
<Button type="button" onClick={() => setSearch(searchDraft.trim())} size="icon" aria-label="جستجو">
<Search className="h-5 w-5 bold" />
</Button>
<Input
value={searchDraft}
onChange={(event) => setSearchDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") setSearch(searchDraft.trim());
}}
placeholder="جستجو..."
className="text-right"
/>
</div>
{usersQuery.isLoading ? (
<div className="flex justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : usersQuery.data?.length ? (
<div className="space-y-2">
{usersQuery.data.map((item) => (
<button
key={item.id}
type="button"
onClick={() => void selectUser(item)}
className={cn(
"w-full rounded-2xl border p-3 text-right transition hover:bg-muted/40",
selectedUserId === item.id ? "border-primary bg-primary/10" : "border-border/70 bg-background",
)}
>
<div className="flex items-center justify-between gap-2">
<Badge variant={item.is_superuser ? "default" : item.is_staff ? "secondary" : "outline"}>
{item.is_superuser ? "سوپریوزر" : item.is_staff ? "staff" : "کاربر"}
</Badge>
<span className="font-medium">{fullName(item)}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">{item.mobile || item.email || item.username}</p>
</button>
))}
</div>
) : (
<p className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">کاربری یافت نشد.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="text-right">
<div className="flex items-center gap-2">
<UserCog className="h-5 w-5 text-primary" />
<CardTitle>نقشهای کاربر</CardTitle>
</div>
<CardDescription>فقط نقشهای آماده قابل تغییر هستند؛ سوپریوزر خواندنی است.</CardDescription>
</CardHeader>
<CardContent>
{!selectedUserId ? (
<div className="rounded-3xl border border-dashed p-10 text-center text-sm text-muted-foreground">یک کاربر را از لیست انتخاب کنید.</div>
) : authQuery.isLoading || !selectedAuth || !effectiveDraft ? (
<div className="flex justify-center py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : (
<div className="space-y-5">
<div className="rounded-3xl border bg-muted/20 p-4 text-right">
<p className="text-lg font-bold">{fullName(selectedAuth)}</p>
<p className="mt-1 text-sm text-muted-foreground">{selectedAuth.mobile || selectedAuth.email || selectedAuth.username}</p>
{isSelf ? (
<p className="mt-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
برای جلوگیری از قفل شدن حساب، نقشهای کاربر فعلی از این صفحه قابل تغییر نیست.
</p>
) : null}
</div>
<div className="space-y-3" dir="ltr">
{selectedAuth.roles.map((role) => {
const isStaffRole = role.key === "staff_admin";
const isSuperuserRole = role.key === "is_superuser";
const checked = isStaffRole
? effectiveDraft.is_staff
: isSuperuserRole
? selectedAuth.is_superuser
: effectiveDraft.groups.includes(role.key);
const disabled = role.locked || isSelf || saving;
return (
<div key={role.key} className="flex items-center justify-between gap-4 rounded-2xl border p-4">
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(value) => {
if (isStaffRole) toggleStaff(value);
else if (!isSuperuserRole) toggleGroup(role.key, value);
}}
/>
<div className="text-right">
<div className="flex items-center justify-end gap-2">
{role.locked ? <ShieldCheck className="h-4 w-4 text-primary" /> : null}
<p className="font-medium">{role.label}</p>
</div>
<p className="mt-1 text-sm text-muted-foreground">{role.description}</p>
</div>
</div>
);
})}
</div>
<div className="flex justify-start gap-2">
<Button variant="outline" onClick={() => setDraftAuth(null)} disabled={saving || isSelf}>
بازنشانی تغییرات
</Button>
<Button onClick={saveAuthorization} disabled={saving || isSelf}>
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ذخیره دسترسیها
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,280 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type CategoryForm = {
name: string;
slug: string;
description: string;
parent_id: string;
};
const emptyForm: CategoryForm = {
name: "",
slug: "",
description: "",
parent_id: "none",
};
export default function AdminBlogCategories() {
const { user } = useAuth();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Types.AdminCategorySchema | null>(null);
const [form, setForm] = useState<CategoryForm>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const canDelete = Boolean(user?.is_superuser);
const categoriesQuery = useQuery({
queryKey: ["admin", "blog", "categories"],
queryFn: () => api.listAdminCategories(),
});
const deletedQuery = useQuery({
queryKey: ["admin", "blog", "categories", "deleted"],
queryFn: () => api.listDeletedCategories(),
enabled: canDelete,
});
const categories = useMemo(() => categoriesQuery.data ?? [], [categoriesQuery.data]);
const visibleCategories = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return categories;
return categories.filter((category) =>
[category.name, category.slug, category.description ?? ""].some((value) => value.toLowerCase().includes(needle)),
);
}, [categories, search]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (category: Types.AdminCategorySchema) => {
setEditing(category);
setForm({
name: category.name,
slug: category.slug,
description: category.description ?? "",
parent_id: category.parent_id ? String(category.parent_id) : "none",
});
setDialogOpen(true);
};
const closeDialog = (force = false) => {
if (submitting && !force) return;
setDialogOpen(false);
setEditing(null);
setForm(emptyForm);
};
const saveCategory = async () => {
const payload: Types.CategoryWriteSchema = {
name: form.name.trim(),
slug: form.slug.trim() || null,
description: form.description,
parent_id: form.parent_id === "none" ? null : Number(form.parent_id),
};
try {
setSubmitting(true);
if (editing) {
await api.updateCategory(editing.id, payload);
} else {
await api.createCategory(payload);
}
toast({ title: editing ? "دسته‌بندی ویرایش شد" : "دسته‌بندی ساخته شد", variant: "success" });
await categoriesQuery.refetch();
closeDialog(true);
} catch (error) {
toast({
title: "ذخیره دسته‌بندی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
const deleteCategory = async (category: Types.AdminCategorySchema) => {
if (!window.confirm(`دسته‌بندی «${category.name}» حذف شود؟`)) return;
try {
await api.deleteCategory(category.id);
toast({ title: "دسته‌بندی حذف شد", variant: "success" });
await Promise.all([categoriesQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "حذف دسته‌بندی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
const restoreCategory = async (category: Types.CategorySchema) => {
try {
await api.restoreCategory(category.id);
toast({ title: "دسته‌بندی بازیابی شد", variant: "success" });
await Promise.all([categoriesQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "بازیابی دسته‌بندی ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-right">
<h2 className="text-2xl font-bold">دستهبندیهای بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">ساخت و مدیریت دستهبندیهای تو در تو برای نوشتهها.</p>
</div>
<Button onClick={openCreate} className="gap-2">
<Plus className="h-4 w-4" />
دستهبندی جدید
</Button>
</div>
<Card>
<CardHeader className="text-right">
<CardTitle>لیست دستهبندیها</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو در نام، اسلاگ یا توضیح..." className="text-right" />
{categoriesQuery.isLoading ? (
<div className="flex justify-center py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : visibleCategories.length ? (
<div className="overflow-hidden rounded-2xl border">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-right">عنوان</th>
{/* <th className="px-4 py-3 text-right">اسلاگ</th> */}
<th className="px-4 py-3 text-right">والد</th>
<th className="px-4 py-3 text-right">تعداد نوشته</th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody>
{visibleCategories.map((category) => (
<tr key={category.id} className="border-t">
<td className="px-4 py-3 font-medium">{category.name}</td>
{/* <td className="px-4 py-3 text-muted-foreground">{category.slug}</td> */}
<td className="px-4 py-3 text-muted-foreground">
{categories.find((item) => item.id === category.parent_id)?.name ?? "—"}
</td>
<td className="px-4 py-3">{toPersianDigits(String(category.post_count))}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => openEdit(category)}>
<Edit3 className="h-3.5 w-3.5" />
</Button>
{canDelete ? (
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteCategory(category)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">دستهبندیای یافت نشد.</p>
)}
</CardContent>
</Card>
{canDelete ? (
<Card>
<CardHeader className="text-right">
<CardTitle>دستهبندیهای حذفشده</CardTitle>
<CardDescription>بازیابی رکوردهای حذف شده.</CardDescription>
</CardHeader>
<CardContent>
{deletedQuery.data?.length ? (
<div className="flex flex-col">
{deletedQuery.data.map((category) => (
<div key={category.id} className="flex items-center justify-between rounded-2xl border p-3">
<span className="font-medium">{category.name}</span>
<Button size="sm" variant="outline" onClick={() => void restoreCategory(category)}>
<RotateCcw className="ml-1 h-3.5 w-3.5" />
بازیابی
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">مورد حذفشدهای وجود ندارد.</p>
)}
</CardContent>
</Card>
) : null}
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent dir="rtl" className="text-right">
<DialogHeader>
<DialogTitle>{editing ? "ویرایش دسته‌بندی" : "دسته‌بندی جدید"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-2 block">نام</Label>
<Input value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div>
<Label className="mb-2 block">اسلاگ اختیاری</Label>
<Input value={form.slug} onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))} dir="ltr" />
</div>
<div>
<Label className="mb-2 block">والد</Label>
<Select value={form.parent_id} onValueChange={(value) => setForm((prev) => ({ ...prev, parent_id: value }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">بدون والد</SelectItem>
{categories.filter((item) => item.id !== editing?.id).map((category) => (
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-2 block">توضیحات</Label>
<Textarea value={form.description} onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))} className="min-h-24" />
</div>
<div className="flex justify-start gap-2">
<Button variant="outline" onClick={() => closeDialog()} disabled={submitting}>انصراف</Button>
<Button onClick={saveCategory} disabled={submitting || !form.name.trim()}>
{submitting ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ذخیره
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

244
src/views/AdminBlogTags.tsx Normal file
View File

@@ -0,0 +1,244 @@
"use client";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { resolveErrorMessage, toPersianDigits } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type TagForm = {
name: string;
slug: string;
};
const emptyForm: TagForm = {
name: "",
slug: "",
};
export default function AdminBlogTags() {
const { user } = useAuth();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Types.AdminTagSchema | null>(null);
const [form, setForm] = useState<TagForm>(emptyForm);
const [submitting, setSubmitting] = useState(false);
const canDelete = Boolean(user?.is_superuser);
const tagsQuery = useQuery({
queryKey: ["admin", "blog", "tags"],
queryFn: () => api.listAdminTags(),
});
const deletedQuery = useQuery({
queryKey: ["admin", "blog", "tags", "deleted"],
queryFn: () => api.listDeletedTags(),
enabled: canDelete,
});
const tags = useMemo(() => tagsQuery.data ?? [], [tagsQuery.data]);
const visibleTags = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return tags;
return tags.filter((tag) => [tag.name, tag.slug].some((value) => value.toLowerCase().includes(needle)));
}, [search, tags]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setDialogOpen(true);
};
const openEdit = (tag: Types.AdminTagSchema) => {
setEditing(tag);
setForm({ name: tag.name, slug: tag.slug });
setDialogOpen(true);
};
const closeDialog = (force = false) => {
if (submitting && !force) return;
setDialogOpen(false);
setEditing(null);
setForm(emptyForm);
};
const saveTag = async () => {
const payload: Types.TagWriteSchema = {
name: form.name.trim(),
slug: form.slug.trim() || null,
};
try {
setSubmitting(true);
if (editing) {
await api.updateTag(editing.id, payload);
} else {
await api.createTag(payload);
}
toast({ title: editing ? "برچسب ویرایش شد" : "برچسب ساخته شد", variant: "success" });
await tagsQuery.refetch();
closeDialog(true);
} catch (error) {
toast({
title: "ذخیره برچسب ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
const deleteTag = async (tag: Types.AdminTagSchema) => {
if (!window.confirm(`برچسب «${tag.name}» حذف شود؟`)) return;
try {
await api.deleteTag(tag.id);
toast({ title: "برچسب حذف شد", variant: "success" });
await Promise.all([tagsQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "حذف برچسب ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
const restoreTag = async (tag: Types.TagSchema) => {
try {
await api.restoreTag(tag.id);
toast({ title: "برچسب بازیابی شد", variant: "success" });
await Promise.all([tagsQuery.refetch(), deletedQuery.refetch()]);
} catch (error) {
toast({
title: "بازیابی برچسب ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-right">
<h2 className="text-2xl font-bold">برچسبهای بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">مدیریت موضوعات و برچسبهایی که روی نوشتهها استفاده میشوند.</p>
</div>
<Button onClick={openCreate} className="gap-2">
<Plus className="h-4 w-4" />
برچسب جدید
</Button>
</div>
<Card>
<CardHeader className="text-right">
<CardTitle>لیست برچسبها</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو در نام یا اسلاگ..." className="text-right" />
{tagsQuery.isLoading ? (
<div className="flex justify-center py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : visibleTags.length ? (
<div className="overflow-hidden rounded-2xl border">
<table className="w-full min-w-[620px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 text-right">عنوان</th>
{/* <th className="px-4 py-3 text-right">اسلاگ</th> */}
<th className="px-4 py-3 text-right">تعداد نوشته</th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody>
{visibleTags.map((tag) => (
<tr key={tag.id} className="border-t">
<td className="px-4 py-3 font-medium">{tag.name}</td>
{/* <td className="px-4 py-3 text-muted-foreground">{tag.slug}</td> */}
<td className="px-4 py-3">{toPersianDigits(String(tag.post_count))}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2 justify-end">
<Button size="sm" variant="outline" onClick={() => openEdit(tag)}>
<Edit3 className="h-3.5 w-3.5" />
</Button>
{canDelete ? (
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteTag(tag)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">برچسبی یافت نشد.</p>
)}
</CardContent>
</Card>
{canDelete ? (
<Card>
<CardHeader className="text-right">
<CardTitle>برچسبهای حذفشده</CardTitle>
<CardDescription>بازیابی رکوردهای حذف شده.</CardDescription>
</CardHeader>
<CardContent>
{deletedQuery.data?.length ? (
<div className="flex flex-col">
{deletedQuery.data.map((tag) => (
<div key={tag.id} className="flex items-center justify-between rounded-2xl border p-3">
<span className="font-medium">{tag.name}</span>
<Button size="sm" variant="outline" onClick={() => void restoreTag(tag)}>
<RotateCcw className="ml-1 h-3.5 w-3.5" />
بازیابی
</Button>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">مورد حذفشدهای وجود ندارد.</p>
)}
</CardContent>
</Card>
) : null}
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent dir="rtl" className="text-right">
<DialogHeader>
<DialogTitle>{editing ? "ویرایش برچسب" : "برچسب جدید"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="mb-2 block">نام</Label>
<Input value={form.name} onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div>
<Label className="mb-2 block">اسلاگ اختیاری</Label>
<Input value={form.slug} onChange={(event) => setForm((prev) => ({ ...prev, slug: event.target.value }))} dir="ltr" />
</div>
<div className="flex justify-start gap-2">
<Button variant="outline" onClick={() => closeDialog()} disabled={submitting}>انصراف</Button>
<Button onClick={saveTag} disabled={submitting || !form.name.trim()}>
{submitting ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
ذخیره
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}