feat(frontend): refine blog and profile experience
This commit is contained in:
8
src/app/admin/blog/[id]/assets/page.tsx
Normal file
8
src/app/admin/blog/[id]/assets/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import AdminBlogAssets from "@/views/AdminBlogAssets";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
|
||||
export default async function AdminBlogAssetsPage({ params }: { params: Params }) {
|
||||
const { id } = await params;
|
||||
return <AdminBlogAssets postId={Number(id)} />;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Link } from "@/lib/router";
|
||||
import { PublicApiError, getPublicPost } from "@/lib/public-api";
|
||||
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes";
|
||||
import { formatJalali } from "@/lib/utils";
|
||||
|
||||
type Params = Promise<{ slug: string }>;
|
||||
@@ -36,11 +37,11 @@ export async function generateMetadata({
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(slug);
|
||||
const post = await loadPost(normalizeBlogSlugParam(slug));
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const metaTitle = post.seo_title || post.og_title || post.title;
|
||||
const metaDescription = post.seo_description || post.og_description || description;
|
||||
const canonical = post.canonical_url || `/blog/${post.slug}`;
|
||||
const canonical = post.canonical_url || blogPostPath(post.slug);
|
||||
const image = toAbsoluteUrl(
|
||||
post.og_image_url || post.absolute_featured_image_url || post.featured_image,
|
||||
apiBaseUrl,
|
||||
@@ -54,7 +55,7 @@ export async function generateMetadata({
|
||||
openGraph: {
|
||||
title: post.og_title || metaTitle,
|
||||
description: post.og_description || metaDescription,
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
siteName: "انجمن علمی کامپیوتر شرق گیلان",
|
||||
type: "article",
|
||||
images: [image],
|
||||
@@ -77,7 +78,7 @@ export default async function BlogDetailPage({
|
||||
params: Params;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(slug);
|
||||
const post = await loadPost(normalizeBlogSlugParam(slug));
|
||||
const description = cleanText(post.excerpt || post.content).slice(0, 160);
|
||||
const metaDescription = post.seo_description || post.og_description || description;
|
||||
const image = toAbsoluteUrl(
|
||||
@@ -93,7 +94,7 @@ export default async function BlogDetailPage({
|
||||
image: [image],
|
||||
datePublished: post.published_at || post.created_at,
|
||||
dateModified: post.updated_at,
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { blogPostUrl } from "@/lib/blog-routes";
|
||||
import { getPublicEvents, getPublicPosts } from "@/lib/public-api";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
@@ -34,7 +35,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
routes.push(
|
||||
...posts.map((post) => ({
|
||||
url: `${siteUrl}/blog/${post.slug}`,
|
||||
url: blogPostUrl(siteUrl, post.slug),
|
||||
lastModified: new Date(post.published_at || post.created_at),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.7,
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
// src/components/ModeToggle.tsx
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export default function ModeToggle() {
|
||||
export default function ModeToggle({ className }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const handleToggle = () => {
|
||||
if (theme === 'system' && typeof window !== 'undefined') {
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
setTheme(prefersDark ? 'light' : 'dark');
|
||||
if (theme === "system" && typeof window !== "undefined") {
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
||||
setTheme(prefersDark ? "light" : "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
const isDark =
|
||||
theme === 'dark' ||
|
||||
(theme === 'system' &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement.classList.contains('dark'));
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark"));
|
||||
|
||||
const nextThemeLabel = isDark ? 'روشن' : 'تاریک';
|
||||
const nextThemeLabel = isDark ? "روشن" : "تاریک";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent shadow-none backdrop-blur transition hover:bg-background/45 hover:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||
title={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -2,21 +2,24 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { LayoutDashboard, LogOut, PencilLine, RotateCcw, UserRound } from "lucide-react";
|
||||
import { Link, NavLink } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import ModeToggle from "@/components/ModeToggle";
|
||||
import NotificationsBell from "@/components/NotificationsBell";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NavItem = ({
|
||||
to,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
@@ -32,7 +35,7 @@ const NavItem = ({
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export default function Navbar() {
|
||||
function ProfileAvatarMenu() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const isAdminUser = isAuthenticated && Boolean(user?.is_staff || user?.is_superuser);
|
||||
|
||||
@@ -45,6 +48,82 @@ export default function Navbar() {
|
||||
[user?.first_name, user?.last_name, user?.username],
|
||||
);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Link to="/auth">
|
||||
<Button className="rounded-full px-5">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu dir="rtl">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-full border-0 bg-transparent p-0 shadow-none transition hover:bg-background/45"
|
||||
aria-label="منوی حساب کاربری"
|
||||
>
|
||||
<Avatar className="h-10 w-10 border border-white/30 shadow-sm">
|
||||
<AvatarImage
|
||||
src={
|
||||
user?.profile_picture_preview_url ||
|
||||
user?.profile_picture ||
|
||||
undefined
|
||||
}
|
||||
alt={user?.username || "profile"}
|
||||
/>
|
||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={12} className="w-64 rounded-2xl p-2 text-right">
|
||||
<DropdownMenuLabel className="text-right">
|
||||
{[user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.username || "حساب کاربری"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-start gap-2 rounded-xl">
|
||||
<Link to="/profile">
|
||||
<UserRound className="h-4 w-4" />
|
||||
مشاهده پروفایل
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-start gap-2 rounded-xl">
|
||||
<Link to="/profile?edit=1">
|
||||
<PencilLine className="h-4 w-4" />
|
||||
ویرایش پروفایل
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-start gap-2 rounded-xl">
|
||||
<Link to="/reset-password">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
تغییر یا بازیابی رمز
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdminUser ? (
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-start gap-2 rounded-xl">
|
||||
<Link to="/admin">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
داشبورد مدیریت
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-start gap-2 rounded-xl text-destructive focus:text-destructive">
|
||||
<Link to="/logout">
|
||||
<LogOut className="h-4 w-4" />
|
||||
خروج از حساب
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
|
||||
@@ -56,12 +135,12 @@ export default function Navbar() {
|
||||
<div className="hidden h-10 w-10 items-center justify-center rounded-2xl border border-border/70 bg-background/90 shadow-sm sm:flex">
|
||||
<span className="text-lg font-bold text-primary">گ</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
انجمن علمی کامپیوتر گیلان
|
||||
انجمن علمی مهندسی کامپیوتر
|
||||
</p>
|
||||
<p className="hidden text-xs text-muted-foreground sm:block">
|
||||
رویدادها، بلاگ و حساب کاربری
|
||||
دانشکدهی فنی و مهندسی شرق گیلان
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -72,44 +151,13 @@ export default function Navbar() {
|
||||
<NavItem to="/events">رویدادها</NavItem>
|
||||
<ModeToggle />
|
||||
{isAuthenticated ? <NotificationsBell /> : null}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-3 rounded-full border border-border/70 bg-background/85 px-2 py-1.5 transition hover:bg-muted/70"
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage
|
||||
src={
|
||||
user?.profile_picture_preview_url ||
|
||||
user?.profile_picture ||
|
||||
undefined
|
||||
}
|
||||
alt={user?.username || "profile"}
|
||||
/>
|
||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="max-w-36 truncate text-sm font-medium text-foreground">
|
||||
{user?.first_name || user?.last_name
|
||||
? `${user?.first_name || ""} ${user?.last_name || ""}`.trim()
|
||||
: user?.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAdminUser ? "پروفایل و مدیریت حساب" : "پروفایل من"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/auth">
|
||||
<Button className="rounded-full px-5">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
)}
|
||||
<ProfileAvatarMenu />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
{isAuthenticated ? <NotificationsBell /> : null}
|
||||
<ModeToggle />
|
||||
<ProfileAvatarMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,9 +85,9 @@ export default function NotificationsBell() {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-10 w-10 rounded-full border-border/70 bg-background/85"
|
||||
className="relative h-10 w-10 rounded-full border-0 bg-transparent shadow-none backdrop-blur transition hover:bg-background/45 hover:shadow-sm"
|
||||
aria-label="اعلانها"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
|
||||
@@ -476,6 +476,33 @@ class ApiClient {
|
||||
return this.request<Types.PostAssetSchema[]>(`/api/blog/admin/posts/${postId}/assets`);
|
||||
}
|
||||
|
||||
async uploadBlogPostFeaturedImage(postId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/featured-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(error.error || error.detail || 'Featured image upload failed');
|
||||
}
|
||||
|
||||
return response.json() as Promise<Types.PostDetailSchema>;
|
||||
}
|
||||
|
||||
async deleteBlogPostFeaturedImage(postId: number) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/featured-image`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async uploadBlogPostAsset(
|
||||
postId: number,
|
||||
file: File,
|
||||
|
||||
17
src/lib/blog-routes.ts
Normal file
17
src/lib/blog-routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/$/, "");
|
||||
|
||||
export function normalizeBlogSlugParam(slug: string) {
|
||||
try {
|
||||
return decodeURIComponent(slug);
|
||||
} catch {
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
export function blogPostPath(slug: string) {
|
||||
return `/blog/${encodeURIComponent(normalizeBlogSlugParam(slug))}`;
|
||||
}
|
||||
|
||||
export function blogPostUrl(baseUrl: string, slug: string) {
|
||||
return `${trimTrailingSlash(baseUrl)}${blogPostPath(slug)}`;
|
||||
}
|
||||
187
src/views/AdminBlogAssets.tsx
Normal file
187
src/views/AdminBlogAssets.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowRight, Copy, Loader2, Trash2, UploadCloud } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type Props = {
|
||||
postId: number;
|
||||
};
|
||||
|
||||
export default function AdminBlogAssets({ postId }: Props) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
|
||||
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!Number.isFinite(postId)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [postData, assetData] = await Promise.all([
|
||||
api.getAdminBlogPost(postId),
|
||||
api.listBlogPostAssets(postId),
|
||||
]);
|
||||
setPost(postData);
|
||||
setAssets(assetData);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "دریافت مرکز آپلود ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [postId]);
|
||||
|
||||
const uploadAsset = async (file: File) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const asset = await api.uploadBlogPostAsset(postId, 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];
|
||||
event.currentTarget.value = "";
|
||||
if (file) void uploadAsset(file);
|
||||
};
|
||||
|
||||
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" });
|
||||
};
|
||||
|
||||
const deleteAsset = async (assetId: number) => {
|
||||
setDeletingId(assetId);
|
||||
try {
|
||||
await api.deleteBlogPostAsset(postId, assetId);
|
||||
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
|
||||
toast({ title: "فایل حذف شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "حذف فایل ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
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-reverse 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">
|
||||
{post?.title ? `فایلهای عمومی مرتبط با «${post.title}»` : "فایلهای عمومی این نوشته را مدیریت کنید."}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => router.push(`/admin/blog/${postId}/edit`)}>
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
بازگشت به ویرایش
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>آپلود فایل</CardTitle>
|
||||
<CardDescription>تصاویر، ویدئوها، اسناد و فایلهای فشرده مجاز هستند. لینک مارکداون هر فایل بعد از آپلود قابل کپی است.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={onFileChange} />
|
||||
<Button variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||
{uploading ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <UploadCloud className="ml-2 h-4 w-4" />}
|
||||
آپلود فایل
|
||||
</Button>
|
||||
|
||||
{assets.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{assets.map((asset) => (
|
||||
<div key={asset.id} className="rounded-2xl border p-3">
|
||||
<div className="flex flex-row-reverse items-start justify-between gap-3">
|
||||
<div className="text-right">
|
||||
<div className="flex flex-wrap 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 className="flex shrink-0 gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => copySnippet(asset)}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => deleteAsset(asset.id)}
|
||||
disabled={deletingId === asset.id}
|
||||
>
|
||||
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</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" />
|
||||
) : asset.absolute_file_url ? (
|
||||
<a className="mt-3 block truncate rounded-xl bg-muted px-3 py-2 text-left text-xs underline" href={asset.absolute_file_url} target="_blank" rel="noreferrer">
|
||||
{asset.absolute_file_url}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
هنوز فایلی برای این نوشته آپلود نشده است.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowRight, Copy, Loader2, Save, Send, UploadCloud } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } 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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { resolveErrorMessage } from "@/lib/utils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type Props = {
|
||||
postId: number | null;
|
||||
@@ -41,17 +41,18 @@ const emptyForm: Types.PostCreateSchema = {
|
||||
export default function AdminBlogEditor({ postId }: Props) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const featuredInputRef = 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 [uploadingFeatured, setUploadingFeatured] = useState(false);
|
||||
|
||||
const isNew = postId == null;
|
||||
const featuredImage = post?.absolute_featured_image_preview_url || post?.absolute_featured_image_url || post?.featured_image;
|
||||
const canPersistPost = form.title.trim() && form.content.trim();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getCategories(), api.getTags()])
|
||||
@@ -68,7 +69,6 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
api.getAdminBlogPost(postId)
|
||||
.then((data) => {
|
||||
setPost(data);
|
||||
setAssets(data.assets ?? []);
|
||||
setForm({
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
@@ -87,12 +87,16 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast({ title: "دریافت نوشته ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
toast({
|
||||
title: "دریافت نوشته ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [postId, toast]);
|
||||
|
||||
const canUpload = useMemo(() => Boolean(post?.id), [post?.id]);
|
||||
const selectedTagIds = useMemo(() => form.tag_ids ?? [], [form.tag_ids]);
|
||||
|
||||
const updateForm = <K extends keyof Types.PostCreateSchema>(key: K, value: Types.PostCreateSchema[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -104,14 +108,17 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
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" });
|
||||
toast({
|
||||
title: "ذخیره ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -127,35 +134,74 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
updateForm("status", submitted.status as Types.PostCreateSchema["status"]);
|
||||
toast({ title: "برای بررسی ارسال شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "ارسال ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" });
|
||||
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 ensureSavedPost = async () => {
|
||||
if (post?.id) return post;
|
||||
if (!canPersistPost) {
|
||||
toast({
|
||||
title: "ابتدا نوشته را کامل کنید",
|
||||
description: "برای ذخیره پیشنویس و باز کردن مرکز آپلود، عنوان و متن نوشته لازم است.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return savePost();
|
||||
};
|
||||
|
||||
const openUploadCenter = async () => {
|
||||
const targetPost = await ensureSavedPost();
|
||||
if (targetPost) {
|
||||
router.push(`/admin/blog/${targetPost.id}/assets`);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const onFeaturedImageChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) uploadAsset(file);
|
||||
event.currentTarget.value = "";
|
||||
if (!file) return;
|
||||
|
||||
const targetPost = await ensureSavedPost();
|
||||
if (!targetPost) return;
|
||||
|
||||
setUploadingFeatured(true);
|
||||
try {
|
||||
const updated = await api.uploadBlogPostFeaturedImage(targetPost.id, file);
|
||||
setPost(updated);
|
||||
toast({ title: "تصویر شاخص بهروزرسانی شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "آپلود تصویر شاخص ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploadingFeatured(false);
|
||||
}
|
||||
};
|
||||
|
||||
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" });
|
||||
const deleteFeaturedImage = async () => {
|
||||
if (!post?.id) return;
|
||||
setUploadingFeatured(true);
|
||||
try {
|
||||
const updated = await api.deleteBlogPostFeaturedImage(post.id);
|
||||
setPost(updated);
|
||||
toast({ title: "تصویر شاخص حذف شد", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "حذف تصویر شاخص ناموفق بود",
|
||||
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploadingFeatured(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -168,24 +214,24 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="rtl">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-col gap-4 md:flex-row-reverse md:items-center md:justify-between">
|
||||
<div className="text-right">
|
||||
<h2 className="text-2xl font-bold">{isNew ? "نوشته جدید" : "ویرایش نوشته"}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
متن را با مارکداون بنویسید، تصویر شاخص را تنظیم کنید و فایلهای داخل متن را از مرکز آپلود جداگانه مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستوجو.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -256,7 +302,7 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
<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);
|
||||
const selected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<Button
|
||||
key={tag.id}
|
||||
@@ -264,8 +310,10 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
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]);
|
||||
updateForm(
|
||||
"tag_ids",
|
||||
selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id],
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
@@ -280,12 +328,31 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>پیشنمایش</CardTitle>
|
||||
<CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription>
|
||||
<CardTitle>تصویر شاخص</CardTitle>
|
||||
<CardDescription>این تصویر به عنوان تامبنیل کارتهای لیست بلاگ و کاور نوشته استفاده میشود.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-2xl border bg-background p-4">
|
||||
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" />
|
||||
<CardContent className="space-y-4">
|
||||
<input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
|
||||
<div className="overflow-hidden rounded-2xl border bg-muted">
|
||||
{featuredImage ? (
|
||||
<img src={featuredImage} alt={post?.title || form.title || "thumbnail"} className="aspect-video w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-video items-center justify-center text-sm text-muted-foreground">
|
||||
تصویری انتخاب نشده است.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{post?.featured_image || post?.absolute_featured_image_url ? (
|
||||
<Button variant="outline" onClick={deleteFeaturedImage} disabled={uploadingFeatured}>
|
||||
<Trash2 className="ml-2 h-4 w-4" />
|
||||
حذف تصویر
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="secondary" onClick={() => featuredInputRef.current?.click()} disabled={uploadingFeatured || saving}>
|
||||
{uploadingFeatured ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <ImageUp className="ml-2 h-4 w-4" />}
|
||||
انتخاب تصویر
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -293,42 +360,30 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
<Card>
|
||||
<CardHeader className="text-right">
|
||||
<CardTitle>مرکز آپلود</CardTitle>
|
||||
<CardDescription>فایلها عمومی هستند و میتوانید لینک مارکداون آنها را در متن قرار دهید.</CardDescription>
|
||||
<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 className="w-full justify-center rounded-2xl py-6" variant="outline" onClick={openUploadCenter} disabled={saving}>
|
||||
<FolderUp className="ml-2 h-4 w-4" />
|
||||
رفتن به مرکز آپلود
|
||||
<ArrowLeft className="mr-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" />
|
||||
{!post?.id ? (
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
برای نوشته جدید، ابتدا پیشنویس ذخیره میشود و سپس مرکز آپلود باز خواهد شد.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
هنوز فایلی برای این نوشته آپلود نشده است.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
@@ -340,7 +395,7 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
{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()}>
|
||||
<Button onClick={submitForReview} disabled={saving || !canPersistPost}>
|
||||
<Send className="ml-2 h-4 w-4" />
|
||||
ارسال برای بررسی
|
||||
</Button>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { api } from "@/lib/api";
|
||||
import { Link } from "@/lib/router";
|
||||
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { formatJalali, resolveErrorMessage } from "@/lib/utils";
|
||||
|
||||
@@ -114,7 +115,7 @@ export default function AdminBlogPreview({ postId }: Props) {
|
||||
</Button>
|
||||
{post.status === "published" && post.slug ? (
|
||||
<Button asChild>
|
||||
<Link to={`/blog/${post.slug}`}>
|
||||
<Link to={blogPostPath(post.slug)}>
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
نسخه عمومی
|
||||
</Link>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type * as Types from "@/lib/types";
|
||||
import { blogPostPath } from "@/lib/blog-routes";
|
||||
|
||||
type BlogProps = {
|
||||
initialPosts?: Types.PostListSchema[];
|
||||
@@ -80,8 +81,18 @@ export default function Blog({
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{posts.map((post) => (
|
||||
<Link key={post.id} to={`/blog/${encodeURIComponent(post.slug)}`}>
|
||||
<Link key={post.id} to={blogPostPath(post.slug)}>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow">
|
||||
{(post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image) ? (
|
||||
<div className="aspect-video overflow-hidden rounded-t-lg bg-muted">
|
||||
<img
|
||||
src={post.absolute_featured_image_thumbnail_url || post.absolute_featured_image_preview_url || post.absolute_featured_image_url || post.featured_image}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-2">{post.title}</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user