feat(frontend): add blog editor and interactions
This commit is contained in:
186
src/views/AdminBlog.tsx
Normal file
186
src/views/AdminBlog.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { BookOpenText, CheckCircle2, Clock3, Edit, Eye, Loader2, Plus, Send, XCircle } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "پیشنویس",
|
||||
submitted: "در انتظار بررسی",
|
||||
changes_requested: "نیازمند اصلاح",
|
||||
published: "منتشر شده",
|
||||
archived: "آرشیو شده",
|
||||
};
|
||||
|
||||
export default function AdminBlog() {
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [posts, setPosts] = useState<Types.PostListSchema[]>([]);
|
||||
const [status, setStatus] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
|
||||
const canReview = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
|
||||
|
||||
const loadPosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.listAdminBlogPosts({
|
||||
status: status === "all" ? undefined : status,
|
||||
search: search.trim() || undefined,
|
||||
limit: 100,
|
||||
});
|
||||
setPosts(data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "دریافت نوشتهها ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, status, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts();
|
||||
}, [loadPosts]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return posts.reduce<Record<string, number>>((acc, post) => {
|
||||
acc[post.status] = (acc[post.status] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}, [posts]);
|
||||
|
||||
const submitPost = async (postId: number) => {
|
||||
setActingId(postId);
|
||||
try {
|
||||
await api.submitBlogPost(postId);
|
||||
await loadPosts();
|
||||
toast({ title: "نوشته برای بررسی ارسال شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "ارسال ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const reviewPost = async (postId: number, action: Types.PostReviewSchema["action"]) => {
|
||||
setActingId(postId);
|
||||
try {
|
||||
await api.reviewBlogPost(postId, { action });
|
||||
await loadPosts();
|
||||
toast({ title: action === "publish" ? "نوشته منتشر شد" : "درخواست اصلاح ثبت شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "عملیات ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
} finally {
|
||||
setActingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card><CardContent className="flex items-center justify-between p-4"><BookOpenText className="h-5 w-5 text-primary" /><span>کل: {posts.length}</span></CardContent></Card>
|
||||
<Card><CardContent className="flex items-center justify-between p-4"><Clock3 className="h-5 w-5 text-amber-600" /><span>بررسی: {stats.submitted ?? 0}</span></CardContent></Card>
|
||||
<Card><CardContent className="flex items-center justify-between p-4"><CheckCircle2 className="h-5 w-5 text-emerald-600" /><span>منتشر: {stats.published ?? 0}</span></CardContent></Card>
|
||||
<Card><CardContent className="flex items-center justify-between 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">
|
||||
<Button variant="outline" onClick={loadPosts}>جستجو</Button>
|
||||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجو..." className="w-64 text-right" />
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="w-48"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">همه وضعیتها</SelectItem>
|
||||
<SelectItem value="draft">پیشنویس</SelectItem>
|
||||
<SelectItem value="submitted">در انتظار بررسی</SelectItem>
|
||||
<SelectItem value="changes_requested">نیازمند اصلاح</SelectItem>
|
||||
<SelectItem value="published">منتشر شده</SelectItem>
|
||||
<SelectItem value="archived">آرشیو شده</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<CardTitle>نوشتهها</CardTitle>
|
||||
<CardDescription>دسترسی نویسندهها به نوشتههای خودشان محدود میشود.</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{loading ? (
|
||||
<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={`/blog/${post.slug}`}><Eye className="ml-2 h-4 w-4" />مشاهده</Link>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" asChild>
|
||||
<Link to={`/admin/blog/${post.id}/edit`}><Edit className="ml-2 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="ml-2 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 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>
|
||||
<h3 className="font-semibold">{post.title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{post.updated_at ? formatJalali(post.updated_at, false) : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed p-8 text-center text-muted-foreground">
|
||||
نوشتهای پیدا نشد.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
350
src/views/AdminBlogEditor.tsx
Normal file
350
src/views/AdminBlogEditor.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowRight, Copy, Loader2, Save, Send, UploadCloud } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
postId: number | null;
|
||||
};
|
||||
|
||||
const emptyForm: Types.PostCreateSchema = {
|
||||
title: "",
|
||||
content: "",
|
||||
excerpt: "",
|
||||
category_id: null,
|
||||
tag_ids: [],
|
||||
status: "draft",
|
||||
is_featured: false,
|
||||
seo_title: "",
|
||||
seo_description: "",
|
||||
canonical_url: "",
|
||||
og_title: "",
|
||||
og_description: "",
|
||||
noindex: false,
|
||||
focus_keyword: "",
|
||||
};
|
||||
|
||||
export default function AdminBlogEditor({ postId }: Props) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [form, setForm] = useState<Types.PostCreateSchema>(emptyForm);
|
||||
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
|
||||
const [categories, setCategories] = useState<Types.CategorySchema[]>([]);
|
||||
const [tags, setTags] = useState<Types.TagSchema[]>([]);
|
||||
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
|
||||
const [loading, setLoading] = useState(Boolean(postId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const isNew = postId == null;
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getCategories(), api.getTags()])
|
||||
.then(([categoryData, tagData]) => {
|
||||
setCategories(categoryData);
|
||||
setTags(tagData);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) return;
|
||||
setLoading(true);
|
||||
api.getAdminBlogPost(postId)
|
||||
.then((data) => {
|
||||
setPost(data);
|
||||
setAssets(data.assets ?? []);
|
||||
setForm({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
excerpt: data.excerpt ?? "",
|
||||
category_id: data.category?.id ?? null,
|
||||
tag_ids: data.tags.map((tag) => tag.id),
|
||||
status: data.status as Types.PostCreateSchema["status"],
|
||||
is_featured: data.is_featured,
|
||||
seo_title: data.seo_title ?? "",
|
||||
seo_description: data.seo_description ?? "",
|
||||
canonical_url: data.canonical_url ?? "",
|
||||
og_title: data.og_title ?? "",
|
||||
og_description: data.og_description ?? "",
|
||||
noindex: Boolean(data.noindex),
|
||||
focus_keyword: data.focus_keyword ?? "",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({ title: "دریافت نوشته ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [postId, toast]);
|
||||
|
||||
const canUpload = useMemo(() => Boolean(post?.id), [post?.id]);
|
||||
|
||||
const updateForm = <K extends keyof Types.PostCreateSchema>(key: K, value: Types.PostCreateSchema[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const savePost = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { ...form, status: form.status || "draft" };
|
||||
const saved = isNew ? await api.createPost(payload) : await api.updatePost(postId, payload);
|
||||
setPost(saved);
|
||||
setAssets(saved.assets ?? []);
|
||||
toast({ title: "نوشته ذخیره شد", variant: "success" });
|
||||
if (isNew) {
|
||||
router.replace(`/admin/blog/${saved.id}/edit`);
|
||||
}
|
||||
return saved;
|
||||
} catch (error) {
|
||||
toast({ title: "ذخیره ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
return null;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitForReview = async () => {
|
||||
const saved = await savePost();
|
||||
if (!saved) return;
|
||||
try {
|
||||
const submitted = await api.submitBlogPost(saved.id);
|
||||
setPost(submitted);
|
||||
updateForm("status", submitted.status as Types.PostCreateSchema["status"]);
|
||||
toast({ title: "برای بررسی ارسال شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "ارسال ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAsset = async (file: File) => {
|
||||
const targetPost = post ?? (await savePost());
|
||||
if (!targetPost) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const asset = await api.uploadBlogPostAsset(targetPost.id, file, { title: file.name });
|
||||
setAssets((prev) => [asset, ...prev]);
|
||||
toast({ title: "فایل آپلود شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "آپلود ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) uploadAsset(file);
|
||||
event.currentTarget.value = "";
|
||||
};
|
||||
|
||||
const copySnippet = async (asset: Types.PostAssetSchema) => {
|
||||
const snippet = asset.markdown_image || asset.markdown_link || asset.absolute_file_url || "";
|
||||
await navigator.clipboard.writeText(snippet);
|
||||
toast({ title: "کد مارکداون کپی شد", variant: "success" });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center" dir="rtl">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<Button variant="outline" onClick={() => router.push("/admin/blog")}>
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
مارکداون بنویسید، فایلها را در مرکز آپلود همین نوشته قرار دهید، سپس برای بررسی ارسال کنید.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>محتوا و سئو</CardTitle>
|
||||
<CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستجو.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">عنوان</Label>
|
||||
<Input value={form.title} onChange={(event) => updateForm("title", event.target.value)} className="text-right" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">دستهبندی</Label>
|
||||
<Select
|
||||
value={form.category_id ? String(form.category_id) : "none"}
|
||||
onValueChange={(value) => updateForm("category_id", value === "none" ? null : Number(value))}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">بدون دستهبندی</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={String(category.id)}>{category.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">خلاصه</Label>
|
||||
<Textarea value={form.excerpt ?? ""} onChange={(event) => updateForm("excerpt", event.target.value)} className="min-h-20 text-right" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">متن مارکداون</Label>
|
||||
<Textarea
|
||||
value={form.content}
|
||||
onChange={(event) => updateForm("content", event.target.value)}
|
||||
className="min-h-[420px] font-mono text-left"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">SEO Title</Label>
|
||||
<Input value={form.seo_title ?? ""} onChange={(event) => updateForm("seo_title", event.target.value)} className="text-right" maxLength={70} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">Focus Keyword</Label>
|
||||
<Input value={form.focus_keyword ?? ""} onChange={(event) => updateForm("focus_keyword", event.target.value)} className="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">SEO Description</Label>
|
||||
<Textarea value={form.seo_description ?? ""} onChange={(event) => updateForm("seo_description", event.target.value)} className="min-h-20 text-right" maxLength={170} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">Canonical URL</Label>
|
||||
<Input value={form.canonical_url ?? ""} onChange={(event) => updateForm("canonical_url", event.target.value)} dir="ltr" />
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-8">
|
||||
<Label>noindex</Label>
|
||||
<Checkbox checked={Boolean(form.noindex)} onCheckedChange={(checked) => updateForm("noindex", Boolean(checked))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-right">برچسبها</Label>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{tags.map((tag) => {
|
||||
const selected = form.tag_ids?.includes(tag.id);
|
||||
return (
|
||||
<Button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
const current = form.tag_ids ?? [];
|
||||
updateForm("tag_ids", selected ? current.filter((id) => id !== tag.id) : [...current, tag.id]);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>پیشنمایش</CardTitle>
|
||||
<CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-2xl border bg-background p-4">
|
||||
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>مرکز آپلود</CardTitle>
|
||||
<CardDescription>فایلها عمومی هستند و میتوانید لینک مارکداون آنها را در متن قرار دهید.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={onFileChange} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || (!canUpload && saving)}
|
||||
>
|
||||
{uploading ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <UploadCloud className="ml-2 h-4 w-4" />}
|
||||
آپلود فایل
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{assets.length ? assets.map((asset) => (
|
||||
<div key={asset.id} className="rounded-2xl border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => copySnippet(asset)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Badge variant="secondary">{asset.file_type}</Badge>
|
||||
<p className="font-medium">{asset.title}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{asset.mime_type || "file"} · {Math.ceil(asset.size / 1024)} KB</p>
|
||||
</div>
|
||||
</div>
|
||||
{asset.absolute_preview_url ? (
|
||||
<img src={asset.absolute_preview_url} alt={asset.alt_text || asset.title} className="mt-3 aspect-video w-full rounded-xl object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
هنوز فایلی برای این نوشته آپلود نشده است.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-4 z-20 flex flex-wrap justify-end gap-3 rounded-2xl border bg-background/90 p-3 shadow-lg backdrop-blur">
|
||||
<Button variant="secondary" onClick={savePost} disabled={saving}>
|
||||
{saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />}
|
||||
ذخیره پیشنویس
|
||||
</Button>
|
||||
<Button onClick={submitForReview} disabled={saving || !form.title.trim() || !form.content.trim()}>
|
||||
<Send className="ml-2 h-4 w-4" />
|
||||
ارسال برای بررسی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, NavLink, useLocation } from '@/lib/router';
|
||||
import { useMemo } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin/users', label: 'مدیریت کاربران' },
|
||||
{ to: '/admin/events', label: 'مدیریت رویدادها' },
|
||||
{ to: "/admin/users", label: "مدیریت کاربران", requiresStaff: true },
|
||||
{ to: "/admin/events", label: "مدیریت رویدادها", requiresStaff: true },
|
||||
{ to: "/admin/blog", label: "مدیریت بلاگ", requiresStaff: false },
|
||||
] as const;
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const isAdmin = useMemo(
|
||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser),
|
||||
[isAuthenticated, user?.is_staff, user?.is_superuser],
|
||||
const canAccessAdmin = useMemo(
|
||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
|
||||
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
@@ -26,27 +27,34 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
if (!canAccessAdmin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const visibleNavItems = navItems.filter((item) => {
|
||||
if (item.requiresStaff) {
|
||||
return Boolean(user?.is_staff || user?.is_superuser);
|
||||
}
|
||||
return Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background" dir="rtl">
|
||||
<div className="border-b bg-muted/20">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-4 gap-4 flex-row-reverse md:flex-row">
|
||||
<h1 className="text-2xl font-bold">پنل مدیریت</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{navItems.map((item) => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{visibleNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'rounded-full px-4 py-2 text-sm transition',
|
||||
"rounded-full px-4 py-2 text-sm transition",
|
||||
(isActive || location.pathname?.startsWith(item.to))
|
||||
? 'bg-primary text-primary-foreground shadow'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground border',
|
||||
].join(' ')
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-card text-muted-foreground hover:text-foreground border",
|
||||
].join(" ")
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
|
||||
@@ -123,6 +123,12 @@ export default function Profile() {
|
||||
staleTime: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: blogActivity } = useQuery({
|
||||
queryKey: ["my-blog-activity"],
|
||||
queryFn: () => api.getMyBlogActivity(),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const [me, setMe] = useState<Types.UserProfileSchema | null>(user ?? null);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -953,6 +959,15 @@ export default function Profile() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4 text-right md:col-span-2 xl:col-span-4">
|
||||
<p className="font-medium">آمار واقعی فعالیت بلاگ</p>
|
||||
<div className="mt-3 grid gap-3 text-sm text-muted-foreground sm:grid-cols-4">
|
||||
<span>پسندیدهها: {formatNumberPersian(blogActivity?.liked_posts.length ?? 0)}</span>
|
||||
<span>ذخیرهشدهها: {formatNumberPersian(blogActivity?.saved_posts.length ?? 0)}</span>
|
||||
<span>نظرها: {formatNumberPersian(blogActivity?.comments.length ?? 0)}</span>
|
||||
<span>پاسخها: {formatNumberPersian(blogActivity?.replies.length ?? 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ActivityPlaceholderCard
|
||||
icon={Heart}
|
||||
title="پستهای لایکشده"
|
||||
|
||||
Reference in New Issue
Block a user