fix(admin): polish mobile layout and actions

This commit is contained in:
2026-06-12 21:35:17 +03:30
parent 4611c8d63b
commit 1e302d2aa2
5 changed files with 153 additions and 76 deletions

View File

@@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useToast } from "@/hooks/use-toast";
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
@@ -91,37 +92,41 @@ export default function AdminBlog() {
};
return (
<div className="space-y-6" dir="ltr">
<div className="space-y-6" dir="rtl">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Button asChild>
<Link to="/admin/blog/new/edit">
<Plus className="ml-2 h-4 w-4" />
نوشته جدید
</Link>
</Button>
<div className="text-right">
<h2 className="text-2xl font-bold">مدیریت بلاگ</h2>
<p className="mt-1 text-sm text-muted-foreground">
پیشنویسها، صف بررسی، انتشار و اصلاح نوشتهها.
</p>
</div>
<Button asChild>
<Link to="/admin/blog/new/edit">
<Plus className="ml-2 h-4 w-4" />
نوشته جدید
</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card><CardContent className="flex items-center flex-row-reverse gap-2 p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row-reverse gap-2 p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row-reverse gap-2 p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row-reverse gap-2 p-4"><XCircle className="h-5 w-5 text-rose-600" /><span>اصلاح: {stats.changes_requested ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
<Card><CardContent className="flex items-center flex-row gap-2 p-4"><XCircle className="h-5 w-5 text-rose-600" /><span>اصلاح: {stats.changes_requested ?? 0}</span></CardContent></Card>
</div>
<Card>
<CardHeader>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2">
<div className="text-right">
<CardTitle>نوشتهها</CardTitle>
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={loadPosts}>جستجو</Button>
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-64 text-right" />
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-full text-right sm:w-64" />
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
<SelectTrigger className="w-full sm:w-48"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">همه وضعیتها</SelectItem>
<SelectItem value="draft">پیشنویس</SelectItem>
@@ -132,10 +137,6 @@ export default function AdminBlog() {
</SelectContent>
</Select>
</div>
<div className="text-right">
<CardTitle>نوشتهها</CardTitle>
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
@@ -143,35 +144,87 @@ export default function AdminBlog() {
<div className="flex justify-center py-10"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : posts.length ? (
posts.map((post) => (
<div key={post.id} className="flex flex-col gap-3 rounded-2xl border p-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link to={`/admin/blog/${post.id}/preview`}><Eye className="h-4 w-4" /></Link>
</Button>
<Button variant="secondary" size="sm" asChild>
<Link to={`/admin/blog/${post.id}/edit`}><Edit className="h-4 w-4" /></Link>
</Button>
{post.status === "draft" || post.status === "changes_requested" ? (
<Button size="sm" onClick={() => submitPost(post.id)} disabled={actingId === post.id}>
<Send className="h-4 w-4" />
</Button>
) : null}
{canReview && post.status === "submitted" ? (
<>
<Button size="sm" onClick={() => reviewPost(post.id, "publish")} disabled={actingId === post.id}>انتشار</Button>
<Button size="sm" variant="outline" onClick={() => reviewPost(post.id, "request_changes")} disabled={actingId === post.id}>درخواست اصلاح</Button>
</>
) : null}
</div>
<div key={post.id} className="flex gap-3 rounded-2xl border p-4 flex-row items-center justify-between">
<div className="text-right">
<div className="flex flex-wrap items-center justify-end gap-2">
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
<div className="flex flex-col-reverse md:flex-row flex-wrap items-start gap-2">
<h3 className="font-semibold">{post.title}</h3>
<Badge variant={post.status === "published" ? "default" : "secondary"}>{statusLabels[post.status] ?? post.status}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
</p>
</div>
<div className="grid grid-cols-2 grid-flow-col grid-rows-2 gap-2 md:flex md:flex-row md:flex-wrap" dir="ltr">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" asChild className="w-full md:w-auto">
<Link
to={`/admin/blog/${post.id}/preview`}
aria-label="پیش‌نمایش"
className="flex justify-center"
>
<Eye className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>پیشنمایش</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="secondary" size="sm" asChild className="w-full md:w-auto">
<Link
to={`/admin/blog/${post.id}/edit`}
aria-label="ویرایش"
className="flex justify-center"
>
<Edit className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>ویرایش</TooltipContent>
</Tooltip>
{post.status === "draft" || post.status === "changes_requested" ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
onClick={() => submitPost(post.id)}
disabled={actingId === post.id}
aria-label="ارسال برای بررسی"
className="w-full md:w-auto"
>
<Send className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>ارسال برای بررسی</TooltipContent>
</Tooltip>
) : null}
{canReview && post.status === "submitted" ? (
<>
<Button
size="sm"
onClick={() => reviewPost(post.id, "publish")}
disabled={actingId === post.id}
className="w-full md:w-auto"
>
انتشار
</Button>
<Button
size="sm"
variant="outline"
onClick={() => reviewPost(post.id, "request_changes")}
disabled={actingId === post.id}
className="w-full md:w-auto"
>
درخواست اصلاح
</Button>
</>
) : null}
</div>
</div>
))
) : (

View File

@@ -53,6 +53,11 @@ export default function AdminBlogCategories() {
});
const categories = useMemo(() => categoriesQuery.data ?? [], [categoriesQuery.data]);
const rootCategories = useMemo(() => categories.filter((category) => !category.parent_id), [categories]);
const editingHasChildren = useMemo(
() => Boolean(editing && categories.some((category) => category.parent_id === editing.id)),
[categories, editing],
);
const visibleCategories = useMemo(() => {
const needle = search.trim().toLowerCase();
if (!needle) return categories;
@@ -166,7 +171,7 @@ export default function AdminBlogCategories() {
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : visibleCategories.length ? (
<div className="overflow-hidden rounded-2xl border">
<div className="overflow-x-auto rounded-2xl border">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
@@ -217,7 +222,7 @@ export default function AdminBlogCategories() {
</CardHeader>
<CardContent>
{deletedQuery.data?.length ? (
<div className="flex flex-col">
<div className="flex flex-col gap-3">
{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>
@@ -251,15 +256,24 @@ export default function AdminBlogCategories() {
</div>
<div>
<Label className="mb-2 block">والد</Label>
<Select value={form.parent_id} onValueChange={(value) => setForm((prev) => ({ ...prev, parent_id: value }))}>
<Select
value={form.parent_id}
onValueChange={(value) => setForm((prev) => ({ ...prev, parent_id: value }))}
disabled={editingHasChildren}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">بدون والد</SelectItem>
{categories.filter((item) => item.id !== editing?.id).map((category) => (
{rootCategories.filter((item) => item.id !== editing?.id).map((category) => (
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
))}
</SelectContent>
</Select>
{editingHasChildren ? (
<p className="mt-2 text-xs text-muted-foreground">
دستهبندیهایی که زیرمجموعه دارند باید در سطح ریشه باقی بمانند.
</p>
) : null}
</div>
<div>
<Label className="mb-2 block">توضیحات</Label>

View File

@@ -226,9 +226,9 @@ export default function AdminBlogEditor({ postId }: Props) {
<div className="rounded-3xl border bg-background/90 p-4 shadow-sm">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="text-right">
<div className="flex flex-wrap items-center justify-end gap-2">
<Badge variant="outline">{form.status || "draft"}</Badge>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
<Badge variant="outline">{form.status || "draft"}</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
نوشتن مارکداون، پیشنمایش زنده و تنظیمات انتشار در یک محیط مینیمال.
@@ -311,7 +311,7 @@ export default function AdminBlogEditor({ postId }: Props) {
<div>
<Label className="mb-2 block text-right">برچسبها</Label>
<div className="flex flex-wrap justify-end gap-2">
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
const selected = selectedTagIds.includes(tag.id);
return (
@@ -332,7 +332,7 @@ export default function AdminBlogEditor({ postId }: Props) {
{canAssignWriters ? (
<div>
<Label className="mb-2 block text-right">نویسندگان</Label>
<div className="flex flex-wrap justify-end gap-2">
<div className="flex flex-wrap gap-2">
{users.map((writer) => {
const selected = selectedWriterIds.includes(writer.id);
const fullName = [writer.first_name, writer.last_name].filter(Boolean).join(" ") || writer.username;

View File

@@ -150,7 +150,7 @@ export default function AdminBlogTags() {
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : visibleTags.length ? (
<div className="overflow-hidden rounded-2xl border">
<div className="overflow-x-auto rounded-2xl border">
<table className="w-full min-w-[620px] text-sm">
<thead className="bg-muted/40 text-muted-foreground">
<tr>
@@ -197,7 +197,7 @@ export default function AdminBlogTags() {
</CardHeader>
<CardContent>
{deletedQuery.data?.length ? (
<div className="flex flex-col">
<div className="flex flex-col gap-3">
{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>

View File

@@ -80,7 +80,7 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
<div className="flex min-h-screen">
<aside
className={cn(
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 ease-in-out md:flex md:flex-col",
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 ease-in-out lg:flex lg:flex-col",
collapsed ? "w-20" : "w-72",
)}
>
@@ -121,38 +121,48 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
</aside>
<div className="min-w-0 flex-1">
<div className="border-b bg-background/90 md:hidden">
<div className="flex items-center justify-between px-4 py-3">
<div className="border-b bg-background/90 lg:hidden">
<div className="px-4 py-3 text-right">
<h1 className="text-lg font-bold">پنل مدیریت</h1>
<div className="flex gap-2 overflow-x-auto">
{visibleNavItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
cn(
"flex shrink-0 items-center gap-1 rounded-full px-3 py-2 text-xs",
isActive || isItemActive(item.to)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground",
)
}
>
<Icon className="h-4 w-4" />
{item.label}
</NavLink>
);
})}
</div>
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
</div>
</div>
<div className="container mx-auto px-4 py-6">
<div className="container mx-auto min-w-0 px-3 pb-28 pt-4 sm:px-4 lg:py-6">
{children}
</div>
</div>
</div>
<div
className="fixed inset-x-0 z-50 px-4 lg:hidden"
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
>
<nav
aria-label="Admin mobile navigation"
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
dir="rtl"
>
{visibleNavItems.map((item) => {
const Icon = item.icon;
const active = isItemActive(item.to);
return (
<NavLink
key={item.to}
to={item.to}
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[10px] font-medium transition-all",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
)}
aria-current={active ? "page" : undefined}
>
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
<span className="max-w-full truncate">{item.label}</span>
</NavLink>
);
})}
</nav>
</div>
</div>
);
}