Compare commits
17 Commits
a76a8a96ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b64c6cf612 | |||
| 958400a8c1 | |||
| da8d82955e | |||
| 021bee9444 | |||
| ecd4a57da9 | |||
| f30d53df7e | |||
| 4edf8a0736 | |||
| 4fb44fcb4c | |||
| 268dd26d9a | |||
| 6ba8f6ec8b | |||
| e3ddb733ee | |||
| 9f07c0740d | |||
| 83321c1d39 | |||
| 6c3a7ed5f4 | |||
| 5c15727516 | |||
| fc94ceb9f5 | |||
| 4e24b96068 |
5
src/app/admin/dashboard/page.tsx
Normal file
5
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import AdminDashboard from "@/views/AdminDashboard";
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
return <AdminDashboard />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/users");
|
||||
redirect("/admin/dashboard");
|
||||
}
|
||||
|
||||
58
src/components/ConfirmAction.tsx
Normal file
58
src/components/ConfirmAction.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
type ConfirmActionProps = {
|
||||
trigger: ReactNode;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm: () => unknown | Promise<unknown>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ConfirmAction({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "حذف",
|
||||
cancelLabel = "انصراف",
|
||||
onConfirm,
|
||||
disabled = false,
|
||||
}: ConfirmActionProps) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
{trigger}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent dir="rtl">
|
||||
<AlertDialogHeader className="text-right">
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="leading-7">{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:justify-start">
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={disabled}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
@@ -33,15 +34,21 @@ function NotificationItem({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void onDelete(notification)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<ConfirmAction
|
||||
title="حذف اعلان"
|
||||
description="آیا از حذف این اعلان مطمئن هستید؟"
|
||||
onConfirm={() => onDelete(notification)}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onOpen(notification)}
|
||||
|
||||
@@ -416,6 +416,60 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminDashboard(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
event_id?: number;
|
||||
granularity?: 'auto' | 'day' | 'week' | 'month';
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||
if (params?.granularity) query.set('granularity', params.granularity);
|
||||
return this.request<Types.AdminDashboardAnalyticsSchema>(
|
||||
`/api/analytics/admin/dashboard${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminUserAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
return this.request<Types.UserAnalyticsSchema>(
|
||||
`/api/analytics/admin/users${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminEventAnalytics(params?: { date_from?: string; date_to?: string; event_id?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||
return this.request<Types.EventAnalyticsSchema>(
|
||||
`/api/analytics/admin/events${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminBlogAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
return this.request<Types.BlogAnalyticsSchema>(
|
||||
`/api/analytics/admin/blog${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminDashboardEventOptions(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.AnalyticsEventOptionsSchema>(
|
||||
`/api/analytics/admin/events/options${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Blog Endpoints =============
|
||||
|
||||
async getPosts(params?: {
|
||||
|
||||
191
src/lib/types.ts
191
src/lib/types.ts
@@ -753,6 +753,197 @@ export interface PaginatedResponse<T> {
|
||||
previous?: string;
|
||||
}
|
||||
|
||||
// Admin analytics
|
||||
export interface AnalyticsPointSchema {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPointGroupSchema {
|
||||
items: AnalyticsPointSchema[];
|
||||
top_items: AnalyticsPointSchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTrendPointSchema {
|
||||
date: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsRegistrationStatusSchema {
|
||||
status: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopEventSchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
attendees: number;
|
||||
capacity?: number | null;
|
||||
fill_rate?: number | null;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularitySchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularityGroupSchema {
|
||||
items: AnalyticsPostPopularitySchema[];
|
||||
top_items: AnalyticsPostPopularitySchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopPostSchema extends AnalyticsPostPopularitySchema {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
event_id?: number | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
};
|
||||
summary: {
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
total_events: number;
|
||||
total_registrations: number;
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
published_posts: number;
|
||||
total_likes: number;
|
||||
total_saves: number;
|
||||
total_comments: number;
|
||||
};
|
||||
users: {
|
||||
signup_trend: AnalyticsTrendPointSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
by_year: AnalyticsPointSchema[];
|
||||
};
|
||||
events: {
|
||||
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
top_events: AnalyticsTopEventSchema[];
|
||||
registration_trend: AnalyticsTrendPointSchema[];
|
||||
};
|
||||
revenue: {
|
||||
trend: AnalyticsTrendPointSchema[];
|
||||
by_event: AnalyticsPointSchema[];
|
||||
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||
total_paid: number;
|
||||
total_discount: number;
|
||||
total_base: number;
|
||||
};
|
||||
blog: {
|
||||
totals: {
|
||||
posts: number;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
};
|
||||
post_popularity: AnalyticsPostPopularitySchema[];
|
||||
top_posts: AnalyticsTopPostSchema[];
|
||||
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||
by_category: AnalyticsPointSchema[];
|
||||
by_tag: AnalyticsPointSchema[];
|
||||
};
|
||||
achievements: {
|
||||
distinct_participants: number;
|
||||
learning_hours: number;
|
||||
published_content: number;
|
||||
community_engagement: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyticsEventOptionsSchema {
|
||||
count: number;
|
||||
results: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UserAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
};
|
||||
summary: {
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
unverified_users: number;
|
||||
profile_completion_rate: number;
|
||||
};
|
||||
signup_trend: AnalyticsTrendPointSchema[];
|
||||
by_major: AnalyticsPointGroupSchema;
|
||||
by_university: AnalyticsPointGroupSchema;
|
||||
by_year: AnalyticsPointGroupSchema;
|
||||
}
|
||||
|
||||
export interface EventAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
event_id?: number | null;
|
||||
};
|
||||
summary: {
|
||||
total_events: number;
|
||||
total_registrations: number;
|
||||
distinct_participants: number;
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
total_base: number;
|
||||
learning_hours: number;
|
||||
};
|
||||
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||
attendee_by_major: AnalyticsPointGroupSchema;
|
||||
attendee_by_university: AnalyticsPointGroupSchema;
|
||||
registration_trend: AnalyticsTrendPointSchema[];
|
||||
revenue_trend: AnalyticsTrendPointSchema[];
|
||||
revenue_by_event: AnalyticsPointGroupSchema;
|
||||
top_events: {
|
||||
top_items: AnalyticsTopEventSchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlogAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
};
|
||||
summary: {
|
||||
published_posts: number;
|
||||
total_likes: number;
|
||||
total_saves: number;
|
||||
total_comments: number;
|
||||
community_engagement: number;
|
||||
};
|
||||
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||
post_popularity: AnalyticsPostPopularityGroupSchema;
|
||||
top_posts: AnalyticsTopPostSchema[];
|
||||
by_category: AnalyticsPointGroupSchema;
|
||||
by_tag: AnalyticsPointGroupSchema;
|
||||
}
|
||||
|
||||
// payment
|
||||
export interface CreatePaymentOut {
|
||||
start_pay_url: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -342,16 +343,23 @@ export default function AdminBlogAssets({ postId }: Props) {
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => deleteAsset(asset.id)}
|
||||
<ConfirmAction
|
||||
title="حذف فایل"
|
||||
description={`آیا از حذف فایل «${asset.title}» مطمئن هستید؟ لینکهای استفادهشده از این فایل دیگر کار نخواهند کرد.`}
|
||||
onConfirm={() => deleteAsset(asset.id)}
|
||||
disabled={deletingId === asset.id}
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={deletingId === asset.id}
|
||||
aria-label="حذف فایل"
|
||||
>
|
||||
{deletingId === asset.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
@@ -119,7 +120,6 @@ export default function AdminBlogCategories() {
|
||||
};
|
||||
|
||||
const deleteCategory = async (category: Types.AdminCategorySchema) => {
|
||||
if (!window.confirm(`دستهبندی «${category.name}» حذف شود؟`)) return;
|
||||
try {
|
||||
await api.deleteCategory(category.id);
|
||||
toast({ title: "دستهبندی حذف شد", variant: "success" });
|
||||
@@ -197,9 +197,16 @@ export default function AdminBlogCategories() {
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteCategory(category)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<ConfirmAction
|
||||
title="حذف دستهبندی"
|
||||
description={`آیا از حذف دستهبندی «${category.name}» مطمئن هستید؟`}
|
||||
onConfirm={() => deleteCategory(category)}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, FolderUp, ImageUp, Loader2, Save, Send, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import MarkdownEditor, { type MarkdownDirectionMode } from "@/components/MarkdownEditor";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
@@ -384,10 +385,18 @@ export default function AdminBlogEditor({ postId }: Props) {
|
||||
</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}>
|
||||
<ConfirmAction
|
||||
title="حذف تصویر شاخص"
|
||||
description="آیا از حذف تصویر شاخص این نوشته مطمئن هستید؟"
|
||||
onConfirm={deleteFeaturedImage}
|
||||
disabled={uploadingFeatured}
|
||||
trigger={
|
||||
<Button variant="outline" disabled={uploadingFeatured}>
|
||||
<Trash2 className="ml-2 h-4 w-4" />
|
||||
حذف تصویر
|
||||
</Button>
|
||||
</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" />}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Edit3, Loader2, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { api } from "@/lib/api";
|
||||
import type * as Types from "@/lib/types";
|
||||
@@ -98,7 +99,6 @@ export default function AdminBlogTags() {
|
||||
};
|
||||
|
||||
const deleteTag = async (tag: Types.AdminTagSchema) => {
|
||||
if (!window.confirm(`برچسب «${tag.name}» حذف شود؟`)) return;
|
||||
try {
|
||||
await api.deleteTag(tag.id);
|
||||
toast({ title: "برچسب حذف شد", variant: "success" });
|
||||
@@ -172,9 +172,16 @@ export default function AdminBlogTags() {
|
||||
<Edit3 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canDelete ? (
|
||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive" onClick={() => void deleteTag(tag)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<ConfirmAction
|
||||
title="حذف برچسب"
|
||||
description={`آیا از حذف برچسب «${tag.name}» مطمئن هستید؟`}
|
||||
onConfirm={() => deleteTag(tag)}
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit3, Plus, Trash2 } from "lucide-react";
|
||||
import AdminDateTimeField from "@/components/AdminDateTimeField";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -160,9 +161,17 @@ export default function AdminCoupons() {
|
||||
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" className="text-destructive hover:text-destructive" onClick={() => deleteMutation.mutate(item.id)} aria-label="حذف">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<ConfirmAction
|
||||
title="حذف کد تخفیف"
|
||||
description={`آیا از حذف کد «${item.code}» مطمئن هستید؟ این کد دیگر در لیستهای عادی نمایش داده نمیشود.`}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
trigger={
|
||||
<Button size="icon" variant="outline" className="text-destructive hover:text-destructive" disabled={deleteMutation.isPending} aria-label="حذف">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
1369
src/views/AdminDashboard.tsx
Normal file
1369
src/views/AdminDashboard.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -354,9 +355,17 @@ export default function AdminEventForm({ mode }: { mode: Mode }) {
|
||||
</button>
|
||||
<div className="flex items-center justify-between gap-2 p-3 text-sm">
|
||||
<span className="truncate">{item.title}</span>
|
||||
<Button size="icon" variant="ghost" className="text-destructive" onClick={() => galleryDeleteMutation.mutate(item.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Building2,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
FolderTree,
|
||||
GraduationCap,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
ShieldCheck,
|
||||
@@ -19,10 +20,17 @@ import {
|
||||
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navGroups = [
|
||||
{
|
||||
key: "dashboard",
|
||||
label: "داشبورد",
|
||||
items: [
|
||||
{ to: "/admin/dashboard", label: "داشبورد", icon: LayoutDashboard, visibility: "staff" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
label: "کاربران",
|
||||
@@ -57,30 +65,12 @@ type NavItem = (typeof navGroups)[number]["items"][number];
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
||||
users: true,
|
||||
events: true,
|
||||
blog: true,
|
||||
});
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const canAccessAdmin = useMemo(
|
||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
|
||||
[isAuthenticated, user?.can_access_blog_admin, user?.is_staff, user?.is_superuser],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = window.localStorage.getItem("admin-sidebar-collapsed");
|
||||
if (saved) setCollapsed(saved === "true");
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
setCollapsed((current) => {
|
||||
const next = !current;
|
||||
window.localStorage.setItem("admin-sidebar-collapsed", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
||||
@@ -103,7 +93,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const visibleGroups = navGroups
|
||||
.map((group) => ({ ...group, items: group.items.filter(canSeeItem) }))
|
||||
.filter((group) => group.items.length > 0);
|
||||
const visibleNavItems = visibleGroups.flatMap((group) => group.items);
|
||||
|
||||
const isItemActive = (to: string) => {
|
||||
if (location.pathname === to) return true;
|
||||
@@ -121,107 +110,129 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
<div className="flex min-h-screen">
|
||||
<aside
|
||||
className={cn(
|
||||
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 ease-in-out lg:flex lg:flex-col",
|
||||
collapsed ? "w-20" : "w-72",
|
||||
"sticky top-0 hidden h-screen shrink-0 border-l bg-background/95 shadow-sm backdrop-blur transition-[width] duration-300 lg:flex lg:flex-col",
|
||||
sidebarCollapsed ? "w-20" : "w-72",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b p-4">
|
||||
{!collapsed ? (
|
||||
<div className="text-right">
|
||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
||||
</div>
|
||||
) : null}
|
||||
<Button variant="ghost" size="icon" onClick={toggleCollapsed} aria-label="باز و بسته کردن منوی مدیریت">
|
||||
{collapsed ? <PanelRightOpen className="h-5 w-5" /> : <PanelRightClose className="h-5 w-5" />}
|
||||
</Button>
|
||||
<div className={cn("border-b p-4", sidebarCollapsed ? "text-center" : "text-right")}>
|
||||
<div className={cn("flex items-center gap-2", sidebarCollapsed ? "justify-center" : "justify-start")}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0 rounded-2xl"
|
||||
onClick={() => setSidebarCollapsed((value) => !value)}
|
||||
aria-label={sidebarCollapsed ? "باز کردن منوی مدیریت" : "جمع کردن منوی مدیریت"}
|
||||
title={sidebarCollapsed ? "باز کردن منو" : "جمع کردن منو"}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelRightOpen className="h-4 w-4" /> : <PanelRightClose className="h-4 w-4" />}
|
||||
</Button>
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-3 p-3">
|
||||
{visibleGroups.map((group) => (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={collapsed ? true : openGroups[group.key]}
|
||||
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.key]: open }))}
|
||||
>
|
||||
{!collapsed ? (
|
||||
<CollapsibleTrigger className="mb-1 flex w-full items-center justify-between rounded-xl px-3 py-2 text-xs font-semibold text-muted-foreground hover:bg-muted/70">
|
||||
<span>{group.label}</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 transition-transform", openGroups[group.key] ? "rotate-180" : "")}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
<CollapsibleContent className="space-y-2">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isItemActive(item.to);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
title={collapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
|
||||
collapsed ? "justify-center" : "justify-start",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed ? <span className="font-medium">{item.label}</span> : null}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<div key={group.key} className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
"px-3 py-2 text-xs font-semibold text-muted-foreground transition-opacity",
|
||||
sidebarCollapsed && "sr-only",
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
</p>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isItemActive(item.to);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
title={sidebarCollapsed ? item.label : undefined}
|
||||
className={cn(
|
||||
"flex items-center rounded-2xl px-3 py-3 text-sm transition",
|
||||
sidebarCollapsed ? "justify-center" : "gap-3",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<span className={cn("font-medium", sidebarCollapsed && "sr-only")}>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="border-b bg-background/90 lg:hidden">
|
||||
<div className="px-4 py-3 text-right">
|
||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="text-right">
|
||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
||||
</div>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 rounded-2xl">
|
||||
<Menu className="h-4 w-4" />
|
||||
منو
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="max-h-[82vh] overflow-y-auto rounded-t-[2rem] border-t p-4 pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
dir="rtl"
|
||||
>
|
||||
<SheetHeader className="mt-6 text-right">
|
||||
<SheetTitle>بخشهای پنل مدیریت</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-5 space-y-5">
|
||||
{visibleGroups.map((group) => (
|
||||
<div key={group.key} className="space-y-2">
|
||||
<p className="px-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isItemActive(item.to);
|
||||
return (
|
||||
<SheetClose asChild key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-2xl border px-3 py-3 text-sm transition",
|
||||
active
|
||||
? "border-primary bg-primary text-primary-foreground shadow"
|
||||
: "bg-background text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</NavLink>
|
||||
</SheetClose>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto min-w-0 px-3 pb-28 pt-4 sm:px-4 lg:py-6">
|
||||
<div className="container mx-auto min-w-0 px-3 pb-8 pt-4 sm:px-4 lg:py-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="fixed inset-x-0 z-50 px-4 lg:hidden"
|
||||
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
|
||||
>
|
||||
<nav
|
||||
aria-label="Admin mobile navigation"
|
||||
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
|
||||
dir="rtl"
|
||||
>
|
||||
{visibleNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isItemActive(item.to);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[10px] font-medium transition-all",
|
||||
active
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
|
||||
<span className="max-w-full truncate">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit3, Plus, Trash2 } from "lucide-react";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
@@ -84,7 +85,7 @@ export default function AdminMetaOptions({ kind }: { kind: Kind }) {
|
||||
|
||||
const openEdit = (item: MetaOptionSchema) => {
|
||||
setEditing(item);
|
||||
setForm({ code: item.code, name: item.name });
|
||||
setForm({ code: item.code, name: item.label });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
@@ -142,7 +143,7 @@ export default function AdminMetaOptions({ kind }: { kind: Kind }) {
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<tr key={item.id} className="border-t hover:bg-muted/40">
|
||||
<td className="px-4 py-3 font-medium">{item.name}</td>
|
||||
<td className="px-4 py-3 font-medium">{item.label}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{item.code}</td>
|
||||
<td className="px-4 py-3">{formatNumberPersian(item.user_count ?? 0)}</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -150,15 +151,23 @@ export default function AdminMetaOptions({ kind }: { kind: Kind }) {
|
||||
<Button size="icon" variant="outline" onClick={() => openEdit(item)} aria-label="ویرایش">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
<ConfirmAction
|
||||
title="حذف مورد"
|
||||
description={`آیا از حذف «${item.label}» مطمئن هستید؟ این عملیات رکورد را از لیستهای عادی حذف میکند.`}
|
||||
onConfirm={() => deleteMutation.mutate(item.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
trigger={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => deleteMutation.mutate(item.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||
import BlogThumbnail from "@/components/BlogThumbnail";
|
||||
import ConfirmAction from "@/components/ConfirmAction";
|
||||
import Markdown from "@/components/Markdown";
|
||||
import { Helmet } from "@/lib/helmet";
|
||||
import { Link, Navigate } from "@/lib/router";
|
||||
@@ -395,26 +396,32 @@ export default function Profile() {
|
||||
</span>
|
||||
) : null}
|
||||
{me?.profile_picture ? (
|
||||
<span
|
||||
<ConfirmAction
|
||||
title="حذف تصویر پروفایل"
|
||||
description="آیا از حذف تصویر پروفایل خود مطمئن هستید؟"
|
||||
onConfirm={onDeletePicture}
|
||||
disabled={uploading}
|
||||
trigger={
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void onDeletePicture();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void onDeletePicture();
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-1 right-1 flex h-9 w-9 items-center justify-center rounded-full bg-destructive text-destructive-foreground shadow-lg"
|
||||
aria-label="حذف تصویر پروفایل"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user