diff --git a/src/app/admin/authorizations/page.tsx b/src/app/admin/authorizations/page.tsx new file mode 100644 index 0000000..51ffe32 --- /dev/null +++ b/src/app/admin/authorizations/page.tsx @@ -0,0 +1,5 @@ +import AdminAuthorizations from "@/views/AdminAuthorizations"; + +export default function AdminAuthorizationsPage() { + return ; +} diff --git a/src/app/admin/blog/categories/page.tsx b/src/app/admin/blog/categories/page.tsx new file mode 100644 index 0000000..b01d3f9 --- /dev/null +++ b/src/app/admin/blog/categories/page.tsx @@ -0,0 +1,5 @@ +import AdminBlogCategories from "@/views/AdminBlogCategories"; + +export default function AdminBlogCategoriesPage() { + return ; +} diff --git a/src/app/admin/blog/tags/page.tsx b/src/app/admin/blog/tags/page.tsx new file mode 100644 index 0000000..88ae6e9 --- /dev/null +++ b/src/app/admin/blog/tags/page.tsx @@ -0,0 +1,5 @@ +import AdminBlogTags from "@/views/AdminBlogTags"; + +export default function AdminBlogTagsPage() { + return ; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b41b520..c14a4d8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -397,6 +397,21 @@ class ApiClient { return this.request(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`); } + async listAuthorizationRoles() { + return this.request('/api/auth/roles'); + } + + async getUserAuthorization(userId: number) { + return this.request(`/api/auth/users/${userId}/authorization`); + } + + async updateUserAuthorization(userId: number, data: Types.UserAuthorizationUpdateSchema) { + return this.request(`/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('/api/blog/categories'); } + async listAdminCategories() { + return this.request('/api/blog/admin/categories'); + } + + async createCategory(data: Types.CategoryWriteSchema) { + return this.request('/api/blog/admin/categories', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateCategory(categoryId: number, data: Types.CategoryWriteSchema) { + return this.request(`/api/blog/admin/categories/${categoryId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteCategory(categoryId: number) { + return this.request(`/api/blog/admin/categories/${categoryId}`, { + method: 'DELETE', + }); + } + async getCategory(slug: string) { return this.request(`/api/blog/categories/${slug}`); } @@ -667,6 +706,30 @@ class ApiClient { return this.request('/api/blog/tags'); } + async listAdminTags() { + return this.request('/api/blog/admin/tags'); + } + + async createTag(data: Types.TagWriteSchema) { + return this.request('/api/blog/admin/tags', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async updateTag(tagId: number, data: Types.TagWriteSchema) { + return this.request(`/api/blog/admin/tags/${tagId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + } + + async deleteTag(tagId: number) { + return this.request(`/api/blog/admin/tags/${tagId}`, { + method: 'DELETE', + }); + } + async getTag(slug: string) { return this.request(`/api/blog/tags/${slug}`); } diff --git a/src/lib/types.ts b/src/lib/types.ts index cfbb0ad..f12d3f2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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; diff --git a/src/views/AdminAuthorizations.tsx b/src/views/AdminAuthorizations.tsx new file mode 100644 index 0000000..b0c0245 --- /dev/null +++ b/src/views/AdminAuthorizations.tsx @@ -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) { + 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(null); + const [draftAuth, setDraftAuth] = useState(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 ( +
+
+

مدیریت دسترسی‌ها

+

تخصیص نقش‌های امن و آماده به کاربران. مجوزهای مستقیم Django از این صفحه قابل تغییر نیستند.

+
+ +
+ + + جستجوی کاربر + نام، موبایل، ایمیل یا نام کاربری را جستجو کنید. + + +
+ + setSearchDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") setSearch(searchDraft.trim()); + }} + placeholder="جستجو..." + className="text-right" + /> +
+ {usersQuery.isLoading ? ( +
+ +
+ ) : usersQuery.data?.length ? ( +
+ {usersQuery.data.map((item) => ( + + ))} +
+ ) : ( +

کاربری یافت نشد.

+ )} +
+
+ + + +
+ + نقش‌های کاربر +
+ فقط نقش‌های آماده قابل تغییر هستند؛ سوپریوزر خواندنی است. +
+ + {!selectedUserId ? ( +
یک کاربر را از لیست انتخاب کنید.
+ ) : authQuery.isLoading || !selectedAuth || !effectiveDraft ? ( +
+ +
+ ) : ( +
+
+

{fullName(selectedAuth)}

+

{selectedAuth.mobile || selectedAuth.email || selectedAuth.username}

+ {isSelf ? ( +

+ برای جلوگیری از قفل شدن حساب، نقش‌های کاربر فعلی از این صفحه قابل تغییر نیست. +

+ ) : null} +
+ +
+ {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 ( +
+ { + if (isStaffRole) toggleStaff(value); + else if (!isSuperuserRole) toggleGroup(role.key, value); + }} + /> +
+
+ {role.locked ? : null} +

{role.label}

+
+

{role.description}

+
+
+ ); + })} +
+ +
+ + +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/views/AdminBlogCategories.tsx b/src/views/AdminBlogCategories.tsx new file mode 100644 index 0000000..fe98b97 --- /dev/null +++ b/src/views/AdminBlogCategories.tsx @@ -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(null); + const [form, setForm] = useState(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 ( +
+
+
+

دسته‌بندی‌های بلاگ

+

ساخت و مدیریت دسته‌بندی‌های تو در تو برای نوشته‌ها.

+
+ +
+ + + + لیست دسته‌بندی‌ها + + + setSearch(event.target.value)} placeholder="جستجو در نام، اسلاگ یا توضیح..." className="text-right" /> + {categoriesQuery.isLoading ? ( +
+ +
+ ) : visibleCategories.length ? ( +
+ + + + + {/* */} + + + + + + + {visibleCategories.map((category) => ( + + + {/* */} + + + + + ))} + +
عنواناسلاگوالدتعداد نوشته
{category.name}{category.slug} + {categories.find((item) => item.id === category.parent_id)?.name ?? "—"} + {toPersianDigits(String(category.post_count))} +
+ + {canDelete ? ( + + ) : null} +
+
+
+ ) : ( +

دسته‌بندی‌ای یافت نشد.

+ )} +
+
+ + {canDelete ? ( + + + دسته‌بندی‌های حذف‌شده + بازیابی رکوردهای حذف شده. + + + {deletedQuery.data?.length ? ( +
+ {deletedQuery.data.map((category) => ( +
+ {category.name} + +
+ ))} +
+ ) : ( +

مورد حذف‌شده‌ای وجود ندارد.

+ )} +
+
+ ) : null} + + !open && closeDialog()}> + + + {editing ? "ویرایش دسته‌بندی" : "دسته‌بندی جدید"} + +
+
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} /> +
+
+ + setForm((prev) => ({ ...prev, slug: event.target.value }))} dir="ltr" /> +
+
+ + +
+
+ +