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 { 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
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";
|
"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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user