388 lines
18 KiB
TypeScript
388 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { ImagePlus, Trash2, Upload } from "lucide-react";
|
||
import AdminDateTimeField from "@/components/AdminDateTimeField";
|
||
import ConfirmAction from "@/components/ConfirmAction";
|
||
import Markdown from "@/components/Markdown";
|
||
import MarkdownEditor from "@/components/MarkdownEditor";
|
||
import ProgressiveImage from "@/components/ProgressiveImage";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
|
||
import type { EventCreateSchema, EventDetailSchema, EventGalleryItem } from "@/lib/types";
|
||
import { api } from "@/lib/api";
|
||
import { getEventCardImageUrl, resolveErrorMessage } from "@/lib/utils";
|
||
import { useAuth } from "@/contexts/AuthContext";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
|
||
type Mode = "create" | "edit";
|
||
|
||
const emptyForm = {
|
||
title: "",
|
||
slug: "",
|
||
status: "draft" as EventCreateSchema["status"],
|
||
event_type: "on_site" as EventCreateSchema["event_type"],
|
||
price: "",
|
||
capacity: "",
|
||
start_time: null as string | null,
|
||
end_time: null as string | null,
|
||
registration_start_date: null as string | null,
|
||
registration_end_date: null as string | null,
|
||
location: "",
|
||
address: "",
|
||
online_link: "",
|
||
description: "",
|
||
registration_success_markdown: "",
|
||
};
|
||
|
||
function toForm(event: EventDetailSchema) {
|
||
return {
|
||
title: event.title || "",
|
||
slug: event.slug || "",
|
||
status: event.status || "draft",
|
||
event_type: event.event_type || "on_site",
|
||
price: event.price ? String(Math.floor(Number(event.price) / 10)) : "",
|
||
capacity: event.capacity != null ? String(event.capacity) : "",
|
||
start_time: event.start_time || null,
|
||
end_time: event.end_time || null,
|
||
registration_start_date: event.registration_start_date || null,
|
||
registration_end_date: event.registration_end_date || null,
|
||
location: event.location || "",
|
||
address: event.address || "",
|
||
online_link: event.online_link || "",
|
||
description: event.description || "",
|
||
registration_success_markdown: event.registration_success_markdown || "",
|
||
};
|
||
}
|
||
|
||
export default function AdminEventForm({ mode }: { mode: Mode }) {
|
||
const { user, isAuthenticated, loading } = useAuth();
|
||
const { id } = useParams<{ id?: string }>();
|
||
const eventId = Number(id);
|
||
const navigate = useNavigate();
|
||
const { toast } = useToast();
|
||
const queryClient = useQueryClient();
|
||
const [form, setForm] = React.useState(emptyForm);
|
||
const [previewMode, setPreviewMode] = React.useState<"editor" | "preview">("editor");
|
||
const [galleryPreview, setGalleryPreview] = React.useState<EventGalleryItem | null>(null);
|
||
|
||
const detailQuery = useQuery({
|
||
queryKey: ["admin", "edit-event", eventId],
|
||
queryFn: () => api.getEventAdminDetail(eventId),
|
||
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||
});
|
||
|
||
const galleryQuery = useQuery({
|
||
queryKey: ["admin", "event", eventId, "gallery"],
|
||
queryFn: () => api.listEventGallery(eventId),
|
||
enabled: mode === "edit" && Number.isFinite(eventId) && isAuthenticated,
|
||
});
|
||
|
||
React.useEffect(() => {
|
||
if (detailQuery.data) setForm(toForm(detailQuery.data));
|
||
}, [detailQuery.data]);
|
||
|
||
const makePayload = (): EventCreateSchema => ({
|
||
title: form.title,
|
||
slug: form.slug || null,
|
||
status: form.status,
|
||
event_type: form.event_type,
|
||
price: form.price ? Number(form.price) * 10 : 0,
|
||
capacity: form.capacity ? Number(form.capacity) : null,
|
||
start_time: form.start_time || new Date().toISOString(),
|
||
end_time: form.end_time || form.start_time || new Date().toISOString(),
|
||
registration_start_date: form.registration_start_date,
|
||
registration_end_date: form.registration_end_date,
|
||
location: form.location || null,
|
||
address: form.address || null,
|
||
online_link: form.online_link || null,
|
||
description: form.description || "",
|
||
registration_success_markdown: form.registration_success_markdown || null,
|
||
});
|
||
|
||
const saveMutation = useMutation({
|
||
mutationFn: async () => {
|
||
const payload = makePayload();
|
||
return mode === "edit" ? api.updateEvent(eventId, payload) : api.createEvent(payload);
|
||
},
|
||
onSuccess: (event) => {
|
||
queryClient.invalidateQueries({ queryKey: ["admin", "events"] });
|
||
toast({ title: "رویداد ذخیره شد", variant: "success" });
|
||
navigate(`/admin/events/${event.id}/edit`);
|
||
},
|
||
onError: (error) => toast({ title: "خطا در ذخیره رویداد", description: resolveErrorMessage(error), variant: "destructive" }),
|
||
});
|
||
|
||
const posterMutation = useMutation({
|
||
mutationFn: (file: File) => api.uploadEventFeaturedImage(eventId, file),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["admin", "edit-event", eventId] });
|
||
toast({ title: "پوستر ذخیره شد", variant: "success" });
|
||
},
|
||
onError: (error) => toast({ title: "خطا در آپلود پوستر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||
});
|
||
|
||
const galleryUploadMutation = useMutation({
|
||
mutationFn: (file: File) => api.uploadEventGalleryImage(eventId, file, { title: file.name }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||
toast({ title: "تصویر گالری افزوده شد", variant: "success" });
|
||
},
|
||
onError: (error) => toast({ title: "خطا در آپلود گالری", description: resolveErrorMessage(error), variant: "destructive" }),
|
||
});
|
||
|
||
const galleryDeleteMutation = useMutation({
|
||
mutationFn: (imageId: number) => api.deleteEventGalleryImage(eventId, imageId),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["admin", "event", eventId, "gallery"] });
|
||
toast({ title: "تصویر حذف شد", variant: "success" });
|
||
},
|
||
onError: (error) => toast({ title: "خطا در حذف تصویر", description: resolveErrorMessage(error), variant: "destructive" }),
|
||
});
|
||
|
||
if (loading) return <div className="py-10 text-center text-muted-foreground">در حال بررسی دسترسی...</div>;
|
||
if (!isAuthenticated || !(user?.is_staff || user?.is_superuser)) return <Navigate to="/" />;
|
||
if (mode === "edit" && !Number.isFinite(eventId)) return <div className="py-10 text-center">شناسه رویداد معتبر نیست.</div>;
|
||
|
||
const event = detailQuery.data;
|
||
const gallery = galleryQuery.data ?? event?.gallery_images ?? [];
|
||
|
||
return (
|
||
<div className="space-y-6" dir="rtl">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-black">{mode === "edit" ? "ویرایش رویداد" : "افزودن رویداد"}</h1>
|
||
<p className="text-sm text-muted-foreground">فرم کامل رویداد با توضیحات Markdown، زمانبندی، پوستر و گالری</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" asChild><Link to="/admin/events">بازگشت</Link></Button>
|
||
<Button disabled={saveMutation.isPending || !form.title.trim()} onClick={() => saveMutation.mutate()}>ذخیره</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{detailQuery.isLoading ? <p className="text-sm text-muted-foreground">در حال بارگذاری...</p> : null}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>اطلاعات اصلی</CardTitle>
|
||
<CardDescription>عنوان، وضعیت، نوع رویداد، ظرفیت و هزینه</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label>عنوان</Label>
|
||
<Input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>اسلاگ</Label>
|
||
<Input dir="ltr" value={form.slug} onChange={(event) => setForm((current) => ({ ...current, slug: event.target.value }))} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>وضعیت</Label>
|
||
<Select value={form.status} onValueChange={(value) => setForm((current) => ({ ...current, status: value as typeof form.status }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="draft">پیشنویس</SelectItem>
|
||
<SelectItem value="published">منتشر شده</SelectItem>
|
||
<SelectItem value="cancelled">لغو شده</SelectItem>
|
||
<SelectItem value="completed">برگزار شده</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>نوع</Label>
|
||
<Select value={form.event_type} onValueChange={(value) => setForm((current) => ({ ...current, event_type: value as typeof form.event_type }))}>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="on_site">حضوری</SelectItem>
|
||
<SelectItem value="online">آنلاین</SelectItem>
|
||
<SelectItem value="hybrid">ترکیبی</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>قیمت (تومان)</Label>
|
||
<Input type="number" value={form.price} onChange={(event) => setForm((current) => ({ ...current, price: event.target.value }))} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>ظرفیت</Label>
|
||
<Input type="number" value={form.capacity} onChange={(event) => setForm((current) => ({ ...current, capacity: event.target.value }))} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>محل / مختصات</Label>
|
||
<Input value={form.location} onChange={(event) => setForm((current) => ({ ...current, location: event.target.value }))} />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>آدرس</Label>
|
||
<Input value={form.address} onChange={(event) => setForm((current) => ({ ...current, address: event.target.value }))} />
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<Label>لینک آنلاین</Label>
|
||
<Input dir="ltr" value={form.online_link} onChange={(event) => setForm((current) => ({ ...current, online_link: event.target.value }))} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>زمانبندی</CardTitle>
|
||
<CardDescription>تاریخ شمسی و زمان جداگانه نمایش داده میشود.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||
<AdminDateTimeField label="شروع رویداد" value={form.start_time} required onChange={(value) => setForm((current) => ({ ...current, start_time: value }))} />
|
||
<AdminDateTimeField label="پایان رویداد" value={form.end_time} required onChange={(value) => setForm((current) => ({ ...current, end_time: value }))} />
|
||
<AdminDateTimeField label="شروع ثبتنام" value={form.registration_start_date} onChange={(value) => setForm((current) => ({ ...current, registration_start_date: value }))} />
|
||
<AdminDateTimeField label="پایان ثبتنام" value={form.registration_end_date} onChange={(value) => setForm((current) => ({ ...current, registration_end_date: value }))} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>پوستر</CardTitle>
|
||
<CardDescription>تصویر شاخص رویداد</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{event ? (
|
||
<ProgressiveImage
|
||
src={getEventCardImageUrl(event)}
|
||
alt={event.title}
|
||
wrapperClassName="aspect-video max-w-xl overflow-hidden rounded-2xl bg-muted"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="flex aspect-video max-w-xl items-center justify-center rounded-2xl border bg-muted/30 text-muted-foreground">
|
||
<ImagePlus className="h-8 w-8" />
|
||
</div>
|
||
)}
|
||
{mode === "edit" ? (
|
||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||
<Upload className="h-4 w-4" />
|
||
آپلود پوستر
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
if (file) posterMutation.mutate(file);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
/>
|
||
</label>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود پوستر فعال میشود.</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>متن رویداد</CardTitle>
|
||
<CardDescription>ویرایشگر Markdown و پیشنمایش</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="flex gap-2 md:hidden">
|
||
<Button variant={previewMode === "editor" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("editor")}>ویرایش</Button>
|
||
<Button variant={previewMode === "preview" ? "default" : "outline"} size="sm" onClick={() => setPreviewMode("preview")}>پیشنمایش</Button>
|
||
</div>
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<div className={previewMode === "preview" ? "hidden lg:block" : ""}>
|
||
<MarkdownEditor value={form.description} onChange={(value) => setForm((current) => ({ ...current, description: value }))} minHeight="520px" onSave={() => saveMutation.mutate()} />
|
||
</div>
|
||
<div className={previewMode === "editor" ? "hidden lg:block" : ""}>
|
||
<div className="min-h-[520px] rounded-2xl border bg-background p-5">
|
||
<Markdown content={form.description || "هنوز متنی وارد نشده است."} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>پیام موفقیت ثبتنام</CardTitle>
|
||
<CardDescription>متنی که بعد از ثبتنام موفق به کاربر نمایش داده میشود.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<Textarea
|
||
value={form.registration_success_markdown}
|
||
onChange={(event) => setForm((current) => ({ ...current, registration_success_markdown: event.target.value }))}
|
||
rows={6}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>گالری رویداد</CardTitle>
|
||
<CardDescription>تصاویر مرتبط با رویداد</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{mode === "edit" ? (
|
||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm hover:bg-muted">
|
||
<Upload className="h-4 w-4" />
|
||
آپلود تصویر
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(event) => {
|
||
const files = Array.from(event.target.files ?? []);
|
||
files.forEach((file) => galleryUploadMutation.mutate(file));
|
||
event.currentTarget.value = "";
|
||
}}
|
||
multiple
|
||
/>
|
||
</label>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">پس از ذخیره اولیه، امکان آپلود گالری فعال میشود.</p>
|
||
)}
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
{gallery.map((item) => (
|
||
<div key={item.id} className="overflow-hidden rounded-2xl border bg-card">
|
||
<button type="button" className="block w-full" onClick={() => setGalleryPreview(item)}>
|
||
<ProgressiveImage
|
||
src={item.absolute_image_preview_url || item.absolute_image_url}
|
||
alt={item.title}
|
||
wrapperClassName="aspect-video w-full bg-muted"
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
</button>
|
||
<div className="flex items-center justify-between gap-2 p-3 text-sm">
|
||
<span className="truncate">{item.title}</span>
|
||
<ConfirmAction
|
||
title="حذف تصویر گالری"
|
||
description={`آیا از حذف «${item.title}» از گالری رویداد مطمئن هستید؟`}
|
||
onConfirm={() => galleryDeleteMutation.mutate(item.id)}
|
||
disabled={galleryDeleteMutation.isPending}
|
||
trigger={
|
||
<Button size="icon" variant="ghost" className="text-destructive" disabled={galleryDeleteMutation.isPending}>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{galleryPreview ? (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setGalleryPreview(null)}>
|
||
<img
|
||
src={galleryPreview.absolute_image_preview_url || galleryPreview.absolute_image_url || ""}
|
||
alt={galleryPreview.title}
|
||
className="max-h-[90vh] max-w-full rounded-2xl object-contain"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|