feat(admin): add taxonomy and authorization pages
This commit is contained in:
5
src/app/admin/authorizations/page.tsx
Normal file
5
src/app/admin/authorizations/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminAuthorizations from "@/views/AdminAuthorizations";
|
||||
|
||||
export default function AdminAuthorizationsPage() {
|
||||
return <AdminAuthorizations />;
|
||||
}
|
||||
5
src/app/admin/blog/categories/page.tsx
Normal file
5
src/app/admin/blog/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminBlogCategories from "@/views/AdminBlogCategories";
|
||||
|
||||
export default function AdminBlogCategoriesPage() {
|
||||
return <AdminBlogCategories />;
|
||||
}
|
||||
5
src/app/admin/blog/tags/page.tsx
Normal file
5
src/app/admin/blog/tags/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminBlogTags from "@/views/AdminBlogTags";
|
||||
|
||||
export default function AdminBlogTagsPage() {
|
||||
return <AdminBlogTags />;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
228
src/views/AdminAuthorizations.tsx
Normal file
228
src/views/AdminAuthorizations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
280
src/views/AdminBlogCategories.tsx
Normal file
280
src/views/AdminBlogCategories.tsx
Normal 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
244
src/views/AdminBlogTags.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user