feat(frontend): refine blog and profile experience
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-09 08:53:31 +03:30
parent 8b1fc942cf
commit 8e5096d192
13 changed files with 962 additions and 898 deletions

View 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)} />;
}

View File

@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { PublicApiError, getPublicPost } from "@/lib/public-api"; import { PublicApiError, getPublicPost } from "@/lib/public-api";
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site"; import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes";
import { formatJalali } from "@/lib/utils"; import { formatJalali } from "@/lib/utils";
type Params = Promise<{ slug: string }>; type Params = Promise<{ slug: string }>;
@@ -36,11 +37,11 @@ export async function generateMetadata({
params: Params; params: Params;
}): Promise<Metadata> { }): Promise<Metadata> {
const { slug } = await 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 description = cleanText(post.excerpt || post.content).slice(0, 160);
const metaTitle = post.seo_title || post.og_title || post.title; const metaTitle = post.seo_title || post.og_title || post.title;
const metaDescription = post.seo_description || post.og_description || description; 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( const image = toAbsoluteUrl(
post.og_image_url || post.absolute_featured_image_url || post.featured_image, post.og_image_url || post.absolute_featured_image_url || post.featured_image,
apiBaseUrl, apiBaseUrl,
@@ -54,7 +55,7 @@ export async function generateMetadata({
openGraph: { openGraph: {
title: post.og_title || metaTitle, title: post.og_title || metaTitle,
description: post.og_description || metaDescription, description: post.og_description || metaDescription,
url: `${siteUrl}/blog/${post.slug}`, url: blogPostUrl(siteUrl, post.slug),
siteName: "انجمن علمی کامپیوتر شرق گیلان", siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "article", type: "article",
images: [image], images: [image],
@@ -77,7 +78,7 @@ export default async function BlogDetailPage({
params: Params; params: Params;
}) { }) {
const { slug } = await 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 description = cleanText(post.excerpt || post.content).slice(0, 160);
const metaDescription = post.seo_description || post.og_description || description; const metaDescription = post.seo_description || post.og_description || description;
const image = toAbsoluteUrl( const image = toAbsoluteUrl(
@@ -93,7 +94,7 @@ export default async function BlogDetailPage({
image: [image], image: [image],
datePublished: post.published_at || post.created_at, datePublished: post.published_at || post.created_at,
dateModified: post.updated_at, dateModified: post.updated_at,
url: `${siteUrl}/blog/${post.slug}`, url: blogPostUrl(siteUrl, post.slug),
author: { author: {
"@type": "Person", "@type": "Person",
name: [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username, name: [post.author.first_name, post.author.last_name].filter(Boolean).join(" ") || post.author.username,

View File

@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { blogPostUrl } from "@/lib/blog-routes";
import { getPublicEvents, getPublicPosts } from "@/lib/public-api"; import { getPublicEvents, getPublicPosts } from "@/lib/public-api";
import { siteUrl } from "@/lib/site"; import { siteUrl } from "@/lib/site";
@@ -34,7 +35,7 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
routes.push( routes.push(
...posts.map((post) => ({ ...posts.map((post) => ({
url: `${siteUrl}/blog/${post.slug}`, url: blogPostUrl(siteUrl, post.slug),
lastModified: new Date(post.published_at || post.created_at), lastModified: new Date(post.published_at || post.created_at),
changeFrequency: "monthly" as const, changeFrequency: "monthly" as const,
priority: 0.7, priority: 0.7,

View File

@@ -1,33 +1,37 @@
// src/components/ModeToggle.tsx import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button'; import { useTheme } from "@/components/ThemeProvider";
import { Moon, Sun } from 'lucide-react'; import { cn } from "@/lib/utils";
import { useTheme } from '@/components/ThemeProvider'; import { Moon, Sun } from "lucide-react";
export default function ModeToggle() { export default function ModeToggle({ className }: { className?: string }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const handleToggle = () => { const handleToggle = () => {
if (theme === 'system' && typeof window !== 'undefined') { if (theme === "system" && typeof window !== "undefined") {
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
setTheme(prefersDark ? 'light' : 'dark'); setTheme(prefersDark ? "light" : "dark");
return; return;
} }
setTheme(theme === 'dark' ? 'light' : 'dark'); setTheme(theme === "dark" ? "light" : "dark");
}; };
const isDark = const isDark =
theme === 'dark' || theme === "dark" ||
(theme === 'system' && (theme === "system" &&
typeof document !== 'undefined' && typeof document !== "undefined" &&
document.documentElement.classList.contains('dark')); document.documentElement.classList.contains("dark"));
const nextThemeLabel = isDark ? 'روشن' : 'تاریک'; const nextThemeLabel = isDark ? "روشن" : "تاریک";
return ( return (
<Button <Button
variant="outline" variant="ghost"
size="icon" 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}`} aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
title={`تغییر تم به حالت ${nextThemeLabel}`} title={`تغییر تم به حالت ${nextThemeLabel}`}
onClick={handleToggle} onClick={handleToggle}

View File

@@ -2,21 +2,24 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { LayoutDashboard, LogOut, PencilLine, RotateCcw, UserRound } from "lucide-react";
import { Link, NavLink } from "@/lib/router"; import { Link, NavLink } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import ModeToggle from "@/components/ModeToggle"; import ModeToggle from "@/components/ModeToggle";
import NotificationsBell from "@/components/NotificationsBell"; import NotificationsBell from "@/components/NotificationsBell";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const NavItem = ({ const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
to,
children,
}: {
to: string;
children: ReactNode;
}) => (
<NavLink <NavLink
to={to} to={to}
className={({ isActive }) => className={({ isActive }) =>
@@ -32,7 +35,7 @@ const NavItem = ({
</NavLink> </NavLink>
); );
export default function Navbar() { function ProfileAvatarMenu() {
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const isAdminUser = isAuthenticated && Boolean(user?.is_staff || user?.is_superuser); 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], [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 ( return (
<nav <nav
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55" 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"> <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> <span className="text-lg font-bold text-primary">گ</span>
</div> </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 className="truncate text-sm font-semibold text-foreground sm:text-base">
انجمن علمی کامپیوتر گیلان انجمن علمی مهندسی کامپیوتر
</p> </p>
<p className="hidden text-xs text-muted-foreground sm:block"> <p className="hidden text-xs text-muted-foreground sm:block">
رویدادها، بلاگ و حساب کاربری دانشکدهی فنی و مهندسی شرق گیلان
</p> </p>
</div> </div>
</Link> </Link>
@@ -72,44 +151,13 @@ export default function Navbar() {
<NavItem to="/events">رویدادها</NavItem> <NavItem to="/events">رویدادها</NavItem>
<ModeToggle /> <ModeToggle />
{isAuthenticated ? <NotificationsBell /> : null} {isAuthenticated ? <NotificationsBell /> : null}
<ProfileAvatarMenu />
{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>
)}
</div> </div>
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2 md:hidden">
{isAuthenticated ? <NotificationsBell /> : null} {isAuthenticated ? <NotificationsBell /> : null}
<ModeToggle /> <ModeToggle />
<ProfileAvatarMenu />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -85,9 +85,9 @@ export default function NotificationsBell() {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
size="icon" 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="اعلان‌ها" aria-label="اعلان‌ها"
> >
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />

View File

@@ -476,6 +476,33 @@ class ApiClient {
return this.request<Types.PostAssetSchema[]>(`/api/blog/admin/posts/${postId}/assets`); 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( async uploadBlogPostAsset(
postId: number, postId: number,
file: File, file: File,

17
src/lib/blog-routes.ts Normal file
View 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)}`;
}

View 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>
);
}

View File

@@ -1,21 +1,21 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; 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 { useRouter } from "next/navigation";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import Markdown from "@/components/Markdown"; import Markdown from "@/components/Markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast"; import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { resolveErrorMessage } from "@/lib/utils"; import { resolveErrorMessage } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
type Props = { type Props = {
postId: number | null; postId: number | null;
@@ -41,17 +41,18 @@ const emptyForm: Types.PostCreateSchema = {
export default function AdminBlogEditor({ postId }: Props) { export default function AdminBlogEditor({ postId }: Props) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement | null>(null); const featuredInputRef = useRef<HTMLInputElement | null>(null);
const [form, setForm] = useState<Types.PostCreateSchema>(emptyForm); const [form, setForm] = useState<Types.PostCreateSchema>(emptyForm);
const [post, setPost] = useState<Types.PostDetailSchema | null>(null); const [post, setPost] = useState<Types.PostDetailSchema | null>(null);
const [categories, setCategories] = useState<Types.CategorySchema[]>([]); const [categories, setCategories] = useState<Types.CategorySchema[]>([]);
const [tags, setTags] = useState<Types.TagSchema[]>([]); const [tags, setTags] = useState<Types.TagSchema[]>([]);
const [assets, setAssets] = useState<Types.PostAssetSchema[]>([]);
const [loading, setLoading] = useState(Boolean(postId)); const [loading, setLoading] = useState(Boolean(postId));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false); const [uploadingFeatured, setUploadingFeatured] = useState(false);
const isNew = postId == null; 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(() => { useEffect(() => {
Promise.all([api.getCategories(), api.getTags()]) Promise.all([api.getCategories(), api.getTags()])
@@ -68,7 +69,6 @@ export default function AdminBlogEditor({ postId }: Props) {
api.getAdminBlogPost(postId) api.getAdminBlogPost(postId)
.then((data) => { .then((data) => {
setPost(data); setPost(data);
setAssets(data.assets ?? []);
setForm({ setForm({
title: data.title, title: data.title,
content: data.content, content: data.content,
@@ -87,12 +87,16 @@ export default function AdminBlogEditor({ postId }: Props) {
}); });
}) })
.catch((error) => { .catch((error) => {
toast({ title: "دریافت نوشته ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" }); toast({
title: "دریافت نوشته ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [postId, toast]); }, [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]) => { const updateForm = <K extends keyof Types.PostCreateSchema>(key: K, value: Types.PostCreateSchema[K]) => {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
@@ -104,14 +108,17 @@ export default function AdminBlogEditor({ postId }: Props) {
const payload = { ...form, status: form.status || "draft" }; const payload = { ...form, status: form.status || "draft" };
const saved = isNew ? await api.createPost(payload) : await api.updatePost(postId, payload); const saved = isNew ? await api.createPost(payload) : await api.updatePost(postId, payload);
setPost(saved); setPost(saved);
setAssets(saved.assets ?? []);
toast({ title: "نوشته ذخیره شد", variant: "success" }); toast({ title: "نوشته ذخیره شد", variant: "success" });
if (isNew) { if (isNew) {
router.replace(`/admin/blog/${saved.id}/edit`); router.replace(`/admin/blog/${saved.id}/edit`);
} }
return saved; return saved;
} catch (error) { } catch (error) {
toast({ title: "ذخیره ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" }); toast({
title: "ذخیره ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
return null; return null;
} finally { } finally {
setSaving(false); setSaving(false);
@@ -127,35 +134,74 @@ export default function AdminBlogEditor({ postId }: Props) {
updateForm("status", submitted.status as Types.PostCreateSchema["status"]); updateForm("status", submitted.status as Types.PostCreateSchema["status"]);
toast({ title: "برای بررسی ارسال شد", variant: "success" }); toast({ title: "برای بررسی ارسال شد", variant: "success" });
} catch (error) { } catch (error) {
toast({ title: "ارسال ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" }); toast({
title: "ارسال ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} }
}; };
const uploadAsset = async (file: File) => { const ensureSavedPost = async () => {
const targetPost = post ?? (await savePost()); if (post?.id) return post;
if (!targetPost) return; if (!canPersistPost) {
setUploading(true); toast({
try { title: "ابتدا نوشته را کامل کنید",
const asset = await api.uploadBlogPostAsset(targetPost.id, file, { title: file.name }); description: "برای ذخیره پیش‌نویس و باز کردن مرکز آپلود، عنوان و متن نوشته لازم است.",
setAssets((prev) => [asset, ...prev]); variant: "destructive",
toast({ title: "فایل آپلود شد", variant: "success" }); });
} catch (error) { return null;
toast({ title: "آپلود ناموفق بود", description: resolveErrorMessage(error, "دوباره تلاش کنید"), variant: "destructive" }); }
} finally { return savePost();
setUploading(false); };
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]; const file = event.target.files?.[0];
if (file) uploadAsset(file);
event.currentTarget.value = ""; 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 deleteFeaturedImage = async () => {
const snippet = asset.markdown_image || asset.markdown_link || asset.absolute_file_url || ""; if (!post?.id) return;
await navigator.clipboard.writeText(snippet); setUploadingFeatured(true);
toast({ title: "کد مارک‌داون کپی شد", variant: "success" }); 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) { if (loading) {
@@ -168,24 +214,24 @@ export default function AdminBlogEditor({ postId }: Props) {
return ( return (
<div className="space-y-6" dir="rtl"> <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")}> <Button variant="outline" onClick={() => router.push("/admin/blog")}>
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
بازگشت بازگشت
</Button> </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>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]"> <div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card> <Card>
<CardHeader className="text-right"> <CardHeader className="text-right">
<CardTitle>محتوا و سئو</CardTitle> <CardTitle>محتوا و سئو</CardTitle>
<CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستجو.</CardDescription> <CardDescription>عنوان، متن مارکداون و متادیتای موتورهای جستوجو.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="grid gap-4 md:grid-cols-2"> <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> <Label className="mb-2 block text-right">برچسبها</Label>
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
{tags.map((tag) => { {tags.map((tag) => {
const selected = form.tag_ids?.includes(tag.id); const selected = selectedTagIds.includes(tag.id);
return ( return (
<Button <Button
key={tag.id} key={tag.id}
@@ -264,8 +310,10 @@ export default function AdminBlogEditor({ postId }: Props) {
size="sm" size="sm"
variant={selected ? "default" : "outline"} variant={selected ? "default" : "outline"}
onClick={() => { onClick={() => {
const current = form.tag_ids ?? []; updateForm(
updateForm("tag_ids", selected ? current.filter((id) => id !== tag.id) : [...current, tag.id]); "tag_ids",
selected ? selectedTagIds.filter((id) => id !== tag.id) : [...selectedTagIds, tag.id],
);
}} }}
> >
{tag.name} {tag.name}
@@ -280,12 +328,31 @@ export default function AdminBlogEditor({ postId }: Props) {
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader className="text-right"> <CardHeader className="text-right">
<CardTitle>پیشنمایش</CardTitle> <CardTitle>تصویر شاخص</CardTitle>
<CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription> <CardDescription>این تصویر به عنوان تامبنیل کارتهای لیست بلاگ و کاور نوشته استفاده میشود.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<div className="rounded-2xl border bg-background p-4"> <input ref={featuredInputRef} type="file" accept="image/*" className="hidden" onChange={onFeaturedImageChange} />
<Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" /> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -293,42 +360,30 @@ export default function AdminBlogEditor({ postId }: Props) {
<Card> <Card>
<CardHeader className="text-right"> <CardHeader className="text-right">
<CardTitle>مرکز آپلود</CardTitle> <CardTitle>مرکز آپلود</CardTitle>
<CardDescription>فایلها عمومی هستند و میتوانید لینک مارکداون آنها را در متن قرار دهید.</CardDescription> <CardDescription>فایلهای داخل متن، تصاویر، اسناد و آرشیوها در صفحه جداگانه همین نوشته مدیریت میشوند.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<input ref={fileInputRef} type="file" className="hidden" onChange={onFileChange} /> <Button className="w-full justify-center rounded-2xl py-6" variant="outline" onClick={openUploadCenter} disabled={saving}>
<Button <FolderUp className="ml-2 h-4 w-4" />
variant="secondary" رفتن به مرکز آپلود
onClick={() => fileInputRef.current?.click()} <ArrowLeft className="mr-2 h-4 w-4" />
disabled={uploading || (!canUpload && saving)}
>
{uploading ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <UploadCloud className="ml-2 h-4 w-4" />}
آپلود فایل
</Button> </Button>
<div className="space-y-3"> {!post?.id ? (
{assets.length ? assets.map((asset) => ( <p className="text-right text-xs text-muted-foreground">
<div key={asset.id} className="rounded-2xl border p-3"> برای نوشته جدید، ابتدا پیشنویس ذخیره میشود و سپس مرکز آپلود باز خواهد شد.
<div className="flex items-center justify-between gap-3"> </p>
<Button variant="ghost" size="sm" onClick={() => copySnippet(asset)}> ) : null}
<Copy className="h-4 w-4" /> </CardContent>
</Button> </Card>
<div className="text-right">
<div className="flex items-center justify-end gap-2"> <Card>
<Badge variant="secondary">{asset.file_type}</Badge> <CardHeader className="text-right">
<p className="font-medium">{asset.title}</p> <CardTitle>پیشنمایش</CardTitle>
</div> <CardDescription>همان متن مارکداون بدون ویرایش WYSIWYG.</CardDescription>
<p className="mt-1 text-xs text-muted-foreground">{asset.mime_type || "file"} · {Math.ceil(asset.size / 1024)} KB</p> </CardHeader>
</div> <CardContent>
</div> <div className="rounded-2xl border bg-background p-4">
{asset.absolute_preview_url ? ( <Markdown content={form.content || "هنوز محتوایی نوشته نشده است."} justify size="base" />
<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> </div>
</CardContent> </CardContent>
</Card> </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" />} {saving ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : <Save className="ml-2 h-4 w-4" />}
ذخیره پیشنویس ذخیره پیشنویس
</Button> </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" /> <Send className="ml-2 h-4 w-4" />
ارسال برای بررسی ارسال برای بررسی
</Button> </Button>

View File

@@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site"; import { apiBaseUrl, toAbsoluteUrl } from "@/lib/site";
import { blogPostPath } from "@/lib/blog-routes";
import type * as Types from "@/lib/types"; import type * as Types from "@/lib/types";
import { formatJalali, resolveErrorMessage } from "@/lib/utils"; import { formatJalali, resolveErrorMessage } from "@/lib/utils";
@@ -114,7 +115,7 @@ export default function AdminBlogPreview({ postId }: Props) {
</Button> </Button>
{post.status === "published" && post.slug ? ( {post.status === "published" && post.slug ? (
<Button asChild> <Button asChild>
<Link to={`/blog/${post.slug}`}> <Link to={blogPostPath(post.slug)}>
<ExternalLink className="ml-2 h-4 w-4" /> <ExternalLink className="ml-2 h-4 w-4" />
نسخه عمومی نسخه عمومی
</Link> </Link>

View File

@@ -6,6 +6,7 @@ import { api } from "@/lib/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type * as Types from "@/lib/types"; import type * as Types from "@/lib/types";
import { blogPostPath } from "@/lib/blog-routes";
type BlogProps = { type BlogProps = {
initialPosts?: Types.PostListSchema[]; initialPosts?: Types.PostListSchema[];
@@ -80,8 +81,18 @@ export default function Blog({
) : ( ) : (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => ( {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"> <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> <CardHeader>
<CardTitle className="line-clamp-2">{post.title}</CardTitle> <CardTitle className="line-clamp-2">{post.title}</CardTitle>
<CardDescription> <CardDescription>

File diff suppressed because it is too large Load Diff