fix(admin): polish mobile layout and actions
This commit is contained in:
@@ -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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user