Compare commits
7 Commits
83321c1d39
...
f30d53df7e
| Author | SHA1 | Date | |
|---|---|---|---|
| f30d53df7e | |||
| 4edf8a0736 | |||
| 4fb44fcb4c | |||
| 268dd26d9a | |||
| 6ba8f6ec8b | |||
| e3ddb733ee | |||
| 9f07c0740d |
@@ -760,6 +760,7 @@ export interface AnalyticsPointSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalyticsPointGroupSchema {
|
export interface AnalyticsPointGroupSchema {
|
||||||
|
items: AnalyticsPointSchema[];
|
||||||
top_items: AnalyticsPointSchema[];
|
top_items: AnalyticsPointSchema[];
|
||||||
other_count: number;
|
other_count: number;
|
||||||
total_count: number;
|
total_count: number;
|
||||||
@@ -797,6 +798,7 @@ export interface AnalyticsPostPopularitySchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalyticsPostPopularityGroupSchema {
|
export interface AnalyticsPostPopularityGroupSchema {
|
||||||
|
items: AnalyticsPostPopularitySchema[];
|
||||||
top_items: AnalyticsPostPopularitySchema[];
|
top_items: AnalyticsPostPopularitySchema[];
|
||||||
other_count: number;
|
other_count: number;
|
||||||
total_count: number;
|
total_count: number;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import DateObject from "react-date-object";
|
import DateObject from "react-date-object";
|
||||||
import persian from "react-date-object/calendars/persian";
|
import persian from "react-date-object/calendars/persian";
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Eraser,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
Heart,
|
Heart,
|
||||||
LibraryBig,
|
LibraryBig,
|
||||||
@@ -28,17 +30,16 @@ import {
|
|||||||
Cell,
|
Cell,
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
Scatter,
|
|
||||||
ScatterChart,
|
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
ZAxis,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
|
||||||
import { Badge } from "@/components/ui/badge";
|
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 { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -81,6 +82,26 @@ type SectionState = DateRangeState & {
|
|||||||
eventId?: string | null;
|
eventId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DashboardTab = "users" | "events" | "blog";
|
||||||
|
|
||||||
|
const DASHBOARD_TABS: DashboardTab[] = ["users", "events", "blog"];
|
||||||
|
|
||||||
|
function parseTab(value: string | null): DashboardTab {
|
||||||
|
return DASHBOARD_TABS.includes(value as DashboardTab) ? (value as DashboardTab) : "users";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDateRange(params: Pick<URLSearchParams, "get">, prefix: "users" | "events" | "blog"): DateRangeState {
|
||||||
|
return {
|
||||||
|
from: params.get(`${prefix}_from`) || "",
|
||||||
|
to: params.get(`${prefix}_to`) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setParam(params: URLSearchParams, key: string, value?: string | null) {
|
||||||
|
if (value) params.set(key, value);
|
||||||
|
else params.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
function toApiDate(date: DateObject | null) {
|
function toApiDate(date: DateObject | null) {
|
||||||
if (!date) return "";
|
if (!date) return "";
|
||||||
const gregorian = date.toDate();
|
const gregorian = date.toDate();
|
||||||
@@ -118,7 +139,7 @@ function truncateLabel(value: string, max = 18) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chartHeight(count: number, min = 280) {
|
function chartHeight(count: number, min = 280) {
|
||||||
return Math.max(min, count * 38 + 90);
|
return Math.max(min, count * 32 + 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
function axisWidth(items: AnalyticsPointSchema[]) {
|
function axisWidth(items: AnalyticsPointSchema[]) {
|
||||||
@@ -130,6 +151,10 @@ function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
|
|||||||
return group.top_items;
|
return group.top_items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fullGroupItems(group: AnalyticsPointGroupSchema) {
|
||||||
|
return group.items?.length ? group.items : group.top_items;
|
||||||
|
}
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
@@ -145,14 +170,14 @@ function StatCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="flex items-center justify-between gap-4 p-5">
|
<CardContent className="flex items-center justify-between gap-3 p-4 sm:gap-4 sm:p-5">
|
||||||
<div className="space-y-2 text-right">
|
<div className="space-y-2 text-right">
|
||||||
<p className="text-sm text-muted-foreground">{title}</p>
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
<p className="text-2xl font-black leading-tight tracking-tight">{value}</p>
|
<p className="text-xl font-black leading-tight tracking-tight sm:text-2xl">{value}</p>
|
||||||
<p className="text-xs leading-6 text-muted-foreground">{description}</p>
|
<p className="text-xs leading-6 text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl p-3" style={{ backgroundColor: `${PALETTE[tone]}22`, color: PALETTE[tone] }}>
|
<div className="rounded-2xl p-2.5 sm:p-3" style={{ backgroundColor: `${PALETTE[tone]}22`, color: PALETTE[tone] }}>
|
||||||
<Icon className="h-6 w-6" />
|
<Icon className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -177,7 +202,7 @@ function SectionLoading() {
|
|||||||
|
|
||||||
function EmptyChart({ label = "دادهای برای نمایش وجود ندارد." }: { label?: string }) {
|
function EmptyChart({ label = "دادهای برای نمایش وجود ندارد." }: { label?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[240px] items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">
|
<div className="flex h-[200px] items-center justify-center rounded-xl border border-dashed px-4 text-center text-xs text-muted-foreground sm:h-[240px] sm:text-sm">
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -201,6 +226,7 @@ function DateRangeFilter({
|
|||||||
onChange={(next) => onChange({ ...value, from: toApiDate(next instanceof DateObject ? next : null) })}
|
onChange={(next) => onChange({ ...value, from: toApiDate(next instanceof DateObject ? next : null) })}
|
||||||
calendar={persian}
|
calendar={persian}
|
||||||
locale={persian_fa}
|
locale={persian_fa}
|
||||||
|
onOpenPickNewDate={false}
|
||||||
calendarPosition="bottom-right"
|
calendarPosition="bottom-right"
|
||||||
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
placeholder="تاریخ شروع"
|
placeholder="تاریخ شروع"
|
||||||
@@ -214,6 +240,7 @@ function DateRangeFilter({
|
|||||||
onChange={(next) => onChange({ ...value, to: toApiDate(next instanceof DateObject ? next : null) })}
|
onChange={(next) => onChange({ ...value, to: toApiDate(next instanceof DateObject ? next : null) })}
|
||||||
calendar={persian}
|
calendar={persian}
|
||||||
locale={persian_fa}
|
locale={persian_fa}
|
||||||
|
onOpenPickNewDate={false}
|
||||||
calendarPosition="bottom-right"
|
calendarPosition="bottom-right"
|
||||||
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
placeholder="تاریخ پایان"
|
placeholder="تاریخ پایان"
|
||||||
@@ -221,7 +248,8 @@ function DateRangeFilter({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<Button variant="outline" className="w-full md:w-auto" onClick={onReset}>
|
<Button variant="destructive" className="w-full gap-2 md:w-auto" onClick={onReset}>
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
پاککردن
|
پاککردن
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,21 +268,21 @@ function FilterCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<BarChart3 className="h-5 w-5 text-primary" />
|
<BarChart3 className="h-5 w-5 text-primary" />
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>{children}</CardContent>
|
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">{children}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartViewport({ children, minWidth = 560 }: { children: React.ReactNode; minWidth?: number }) {
|
function ChartViewport({ children, minWidth = 420 }: { children: React.ReactNode; minWidth?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto overflow-y-hidden pb-2">
|
<div className="overflow-x-auto overflow-y-hidden pb-2" dir="ltr">
|
||||||
<div style={{ minWidth }}>{children}</div>
|
<div style={{ minWidth }}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -284,13 +312,285 @@ function CustomValueTooltip({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighCardinalityNotice({ group }: { group: AnalyticsPointGroupSchema }) {
|
function FullResultsAction({
|
||||||
|
group,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
group: AnalyticsPointGroupSchema;
|
||||||
|
onOpen: () => void;
|
||||||
|
}) {
|
||||||
if (group.total_count <= group.top_items.length) return null;
|
if (group.total_count <= group.top_items.length) return null;
|
||||||
return (
|
return (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-col gap-2 rounded-xl border bg-muted/20 p-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
نمایش {formatNumberPersian(group.top_items.length)} مورد برتر از {formatNumberPersian(group.total_count)} مورد؛{" "}
|
<span>
|
||||||
{formatNumberPersian(group.other_count)} مورد دیگر در نمودار فشرده نشدهاند.
|
نمودار خلاصه {formatNumberPersian(group.top_items.length)} مورد اول را نشان میدهد؛ همه{" "}
|
||||||
</p>
|
{formatNumberPersian(group.total_count)} مورد در نمای کامل قابل بررسی است.
|
||||||
|
</span>
|
||||||
|
<Button type="button" size="sm" variant="secondary" className="shrink-0" onClick={onOpen}>
|
||||||
|
مشاهده همه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardPointDetailModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
data,
|
||||||
|
color,
|
||||||
|
unit,
|
||||||
|
formatter,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
data: AnalyticsPointSchema[];
|
||||||
|
color: string;
|
||||||
|
unit?: string;
|
||||||
|
formatter?: (value: number) => string;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [sortBy, setSortBy] = React.useState<"value" | "label">("value");
|
||||||
|
const filteredData = React.useMemo(() => {
|
||||||
|
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
||||||
|
const filtered = normalizedSearch
|
||||||
|
? data.filter((item) => toPersianDigits(item.label).toLocaleLowerCase("fa-IR").includes(normalizedSearch))
|
||||||
|
: data;
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
if (sortBy === "label") return String(a.label).localeCompare(String(b.label), "fa");
|
||||||
|
return Number(b.value) - Number(a.value) || String(a.label).localeCompare(String(b.label), "fa");
|
||||||
|
});
|
||||||
|
}, [data, search, sortBy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-5xl overflow-y-auto rounded-3xl" dir="rtl">
|
||||||
|
<DialogHeader className="mt-6 text-right md:text-right">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-[1fr_auto_auto]">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="جستجو در عنوانها..."
|
||||||
|
className="text-right"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant={sortBy === "value" ? "default" : "outline"} onClick={() => setSortBy("value")}>
|
||||||
|
مرتبسازی با مقدار
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant={sortBy === "label" ? "default" : "outline"} onClick={() => setSortBy("label")}>
|
||||||
|
مرتبسازی الفبایی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!filteredData.length ? (
|
||||||
|
<EmptyChart label="موردی با این جستجو پیدا نشد." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChartViewport minWidth={680}>
|
||||||
|
<ChartContainer
|
||||||
|
config={{ value: { label: "مقدار", color } }}
|
||||||
|
className="w-full"
|
||||||
|
style={{ height: Math.min(Math.max(filteredData.length * 34 + 100, 360), 780) }}
|
||||||
|
>
|
||||||
|
<BarChart
|
||||||
|
data={filteredData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 12, right: axisWidth(filteredData) + 16, bottom: 24, left: 20 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
reversed
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => formatter ? formatter(Number(value)) : formatNumberPersian(Number(value))}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="label"
|
||||||
|
type="category"
|
||||||
|
orientation="right"
|
||||||
|
width={axisWidth(filteredData)}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
tickFormatter={(value) => truncateLabel(String(value), 24)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={formatter} />} />
|
||||||
|
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</ChartViewport>
|
||||||
|
<ValuesTable data={filteredData} unit={unit} formatter={formatter} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostEngagementDatum = AnalyticsPostPopularitySchema & {
|
||||||
|
label: string;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function postScore(post: AnalyticsPostPopularitySchema) {
|
||||||
|
return Number(post.likes || 0) + Number(post.saves || 0) + Number(post.comments || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPostEngagementData(posts: AnalyticsPostPopularitySchema[]): PostEngagementDatum[] {
|
||||||
|
return posts.map((post) => ({
|
||||||
|
...post,
|
||||||
|
label: post.title,
|
||||||
|
score: postScore(post),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostEngagementTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{ payload?: PostEngagementDatum }>;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length || !payload[0].payload) return null;
|
||||||
|
const item = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="min-w-56 rounded-lg border bg-background p-3 text-xs shadow-xl" dir="rtl">
|
||||||
|
<p className="mb-2 max-w-72 font-semibold">{item.title}</p>
|
||||||
|
<div className="space-y-1 text-muted-foreground">
|
||||||
|
<p>لایک: {formatNumberPersian(item.likes)}</p>
|
||||||
|
<p>ذخیره: {formatNumberPersian(item.saves)}</p>
|
||||||
|
<p>کامنت: {formatNumberPersian(item.comments)}</p>
|
||||||
|
<p>تعامل کل: {formatNumberPersian(item.score)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlogPostEngagementModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
posts,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
posts: AnalyticsPostPopularitySchema[];
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [sortBy, setSortBy] = React.useState<"score" | "likes" | "saves" | "comments" | "title">("score");
|
||||||
|
const data = React.useMemo(() => {
|
||||||
|
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
||||||
|
const filtered = normalizedSearch
|
||||||
|
? posts.filter((post) => post.title.toLocaleLowerCase("fa-IR").includes(normalizedSearch))
|
||||||
|
: posts;
|
||||||
|
return toPostEngagementData(filtered).sort((a, b) => {
|
||||||
|
if (sortBy === "title") return a.title.localeCompare(b.title, "fa");
|
||||||
|
return Number(b[sortBy]) - Number(a[sortBy]) || a.title.localeCompare(b.title, "fa");
|
||||||
|
});
|
||||||
|
}, [posts, search, sortBy]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-6xl overflow-y-auto rounded-3xl" dir="rtl">
|
||||||
|
<DialogHeader className="mt-6 text-right md:text-right">
|
||||||
|
<DialogTitle>محبوبیت نوشتهها</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">نمای کامل تعامل نوشتهها با امکان جستجو و مرتبسازی</p>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1fr_repeat(5,auto)]">
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="جستجو در عنوان نوشته..."
|
||||||
|
className="text-right"
|
||||||
|
/>
|
||||||
|
{[
|
||||||
|
["score", "تعامل کل"],
|
||||||
|
["likes", "لایک"],
|
||||||
|
["saves", "ذخیره"],
|
||||||
|
["comments", "کامنت"],
|
||||||
|
["title", "عنوان"],
|
||||||
|
].map(([key, label]) => (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
variant={sortBy === key ? "default" : "outline"}
|
||||||
|
onClick={() => setSortBy(key as typeof sortBy)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!data.length ? (
|
||||||
|
<EmptyChart label="نوشتهای با این جستجو پیدا نشد." />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChartViewport minWidth={760}>
|
||||||
|
<ChartContainer
|
||||||
|
config={{
|
||||||
|
likes: { label: "لایک", color: PALETTE.rose },
|
||||||
|
saves: { label: "ذخیره", color: PALETTE.cyan },
|
||||||
|
comments: { label: "کامنت", color: PALETTE.amber },
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
style={{ height: Math.min(Math.max(data.length * 34 + 100, 380), 820) }}
|
||||||
|
>
|
||||||
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 12, right: axisWidth(data.map((post) => ({ label: post.title, value: post.score }))) + 16, bottom: 24, left: 20 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
|
<XAxis type="number" reversed tickLine={false} axisLine={false} tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
||||||
|
<YAxis
|
||||||
|
dataKey="label"
|
||||||
|
type="category"
|
||||||
|
orientation="right"
|
||||||
|
width={axisWidth(data.map((post) => ({ label: post.title, value: post.score })))}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
tickFormatter={(value) => truncateLabel(String(value), 26)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<PostEngagementTooltip />} />
|
||||||
|
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
||||||
|
<Bar dataKey="saves" stackId="engagement" fill="var(--color-saves)" radius={[0, 0, 0, 0]} />
|
||||||
|
<Bar dataKey="comments" stackId="engagement" fill="var(--color-comments)" radius={[8, 8, 8, 8]} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</ChartViewport>
|
||||||
|
<div className="mt-4 max-h-72 overflow-auto rounded-xl border">
|
||||||
|
<table className="w-full min-w-[620px] text-sm">
|
||||||
|
<thead className="bg-muted/40 text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-right">نوشته</th>
|
||||||
|
<th className="px-3 py-2 text-right">لایک</th>
|
||||||
|
<th className="px-3 py-2 text-right">ذخیره</th>
|
||||||
|
<th className="px-3 py-2 text-right">کامنت</th>
|
||||||
|
<th className="px-3 py-2 text-right">تعامل کل</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((post) => (
|
||||||
|
<tr key={post.id} className="border-t">
|
||||||
|
<td className="px-3 py-2 font-medium">{post.title}</td>
|
||||||
|
<td className="px-3 py-2">{formatNumberPersian(post.likes)}</td>
|
||||||
|
<td className="px-3 py-2">{formatNumberPersian(post.saves)}</td>
|
||||||
|
<td className="px-3 py-2">{formatNumberPersian(post.comments)}</td>
|
||||||
|
<td className="px-3 py-2 font-semibold">{formatNumberPersian(post.score)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +606,7 @@ function ValuesTable({
|
|||||||
if (!data.length) return null;
|
if (!data.length) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 max-h-56 overflow-auto rounded-xl border">
|
<div className="mt-4 max-h-56 overflow-auto rounded-xl border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-xs sm:text-sm">
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((item, index) => (
|
{data.map((item, index) => (
|
||||||
<tr key={`${item.label}-${index}`} className="border-b last:border-b-0">
|
<tr key={`${item.label}-${index}`} className="border-b last:border-b-0">
|
||||||
@@ -340,13 +640,15 @@ function HorizontalBarCard({
|
|||||||
valueFormatter?: (value: number) => string;
|
valueFormatter?: (value: number) => string;
|
||||||
}) {
|
}) {
|
||||||
const data = dataWithOtherNotice(group);
|
const data = dataWithOtherNotice(group);
|
||||||
|
const allData = fullGroupItems(group);
|
||||||
|
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
|
||||||
{!data.length ? (
|
{!data.length ? (
|
||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
@@ -357,15 +659,24 @@ function HorizontalBarCard({
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: chartHeight(data.length) }}
|
style={{ height: chartHeight(data.length) }}
|
||||||
>
|
>
|
||||||
<BarChart data={data} layout="vertical" margin={{ top: 12, right: 16, bottom: 24, left: 20 }}>
|
<BarChart data={data} layout="vertical" margin={{ top: 12, right: axisWidth(data) + 10, bottom: 24, left: 20 }}>
|
||||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis type="number" tickFormatter={(value) => valueFormatter ? valueFormatter(Number(value)) : formatNumberPersian(Number(value))} />
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
reversed
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => valueFormatter ? valueFormatter(Number(value)) : formatNumberPersian(Number(value))}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
type="category"
|
type="category"
|
||||||
|
orientation="right"
|
||||||
width={axisWidth(data)}
|
width={axisWidth(data)}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
tickMargin={10}
|
||||||
tickFormatter={(value) => truncateLabel(String(value))}
|
tickFormatter={(value) => truncateLabel(String(value))}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={valueFormatter} />} />
|
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={valueFormatter} />} />
|
||||||
@@ -373,8 +684,18 @@ function HorizontalBarCard({
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</ChartViewport>
|
</ChartViewport>
|
||||||
<HighCardinalityNotice group={group} />
|
<FullResultsAction group={group} onOpen={() => setDetailsOpen(true)} />
|
||||||
<ValuesTable data={data} unit={unit} formatter={valueFormatter} />
|
<ValuesTable data={data} unit={unit} formatter={valueFormatter} />
|
||||||
|
<DashboardPointDetailModal
|
||||||
|
open={detailsOpen}
|
||||||
|
onOpenChange={setDetailsOpen}
|
||||||
|
title={title}
|
||||||
|
description={`${description}؛ نمایش کامل ${formatNumberPersian(allData.length)} مورد`}
|
||||||
|
data={allData}
|
||||||
|
color={color}
|
||||||
|
unit={unit}
|
||||||
|
formatter={valueFormatter}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -397,29 +718,37 @@ function TrendLineCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
<LineChartIcon className="h-5 w-5 text-primary" />
|
<LineChartIcon className="h-5 w-5 text-primary" />
|
||||||
{title}
|
{title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
|
||||||
{!data.length ? (
|
{!data.length ? (
|
||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
<ChartViewport>
|
<ChartViewport>
|
||||||
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[300px] w-full">
|
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[260px] w-full sm:h-[300px]">
|
||||||
<LineChart data={data} margin={{ top: 16, right: 18, bottom: 32, left: 26 }}>
|
<LineChart data={data} margin={{ top: 16, right: 76, bottom: 32, left: 18 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
minTickGap={34}
|
minTickGap={34}
|
||||||
|
tickMargin={10}
|
||||||
tickFormatter={formatJalaliTick}
|
tickFormatter={formatJalaliTick}
|
||||||
/>
|
/>
|
||||||
<YAxis width={76} tickFormatter={(value) => valueFormatter(Number(value))} />
|
<YAxis
|
||||||
|
orientation="right"
|
||||||
|
width={76}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
tickFormatter={(value) => valueFormatter(Number(value))}
|
||||||
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={({ active, payload }) => {
|
content={({ active, payload }) => {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
@@ -453,23 +782,39 @@ function StatusChartCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
|
||||||
{!data.length ? (
|
{!data.length ? (
|
||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChartViewport minWidth={460}>
|
<ChartViewport minWidth={400}>
|
||||||
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[300px] w-full">
|
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[260px] w-full sm:h-[300px]">
|
||||||
<BarChart data={data} margin={{ top: 14, right: 14, bottom: 44, left: 22 }}>
|
<BarChart data={data} layout="vertical" margin={{ top: 14, right: 118, bottom: 28, left: 18 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="label" tickLine={false} axisLine={false} tickFormatter={(value) => truncateLabel(String(value), 14)} />
|
<XAxis
|
||||||
<YAxis width={64} tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
type="number"
|
||||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
reversed
|
||||||
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="label"
|
||||||
|
type="category"
|
||||||
|
orientation="right"
|
||||||
|
width={108}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
tickFormatter={(value) => truncateLabel(String(value), 14)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<CustomValueTooltip />} />
|
||||||
|
<Bar dataKey="value" radius={[8, 8, 8, 8]}>
|
||||||
{data.map((_, index) => (
|
{data.map((_, index) => (
|
||||||
<Cell key={index} fill={STATUS_COLORS[index % STATUS_COLORS.length]} />
|
<Cell key={index} fill={STATUS_COLORS[index % STATUS_COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
@@ -488,11 +833,11 @@ function StatusChartCard({
|
|||||||
function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend"] }) {
|
function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend"] }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle>روند تعاملات بلاگ</CardTitle>
|
<CardTitle className="text-base sm:text-lg">روند تعاملات بلاگ</CardTitle>
|
||||||
<CardDescription>لایک، ذخیره و کامنت در بازه انتخابی</CardDescription>
|
<CardDescription>لایک، ذخیره و کامنت در بازه انتخابی</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
|
||||||
{!data.length ? (
|
{!data.length ? (
|
||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
@@ -503,12 +848,19 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
|
|||||||
saves: { label: "ذخیره", color: PALETTE.cyan },
|
saves: { label: "ذخیره", color: PALETTE.cyan },
|
||||||
comments: { label: "کامنت", color: PALETTE.amber },
|
comments: { label: "کامنت", color: PALETTE.amber },
|
||||||
}}
|
}}
|
||||||
className="h-[320px] w-full"
|
className="h-[280px] w-full sm:h-[320px]"
|
||||||
>
|
>
|
||||||
<LineChart data={data} margin={{ top: 16, right: 18, bottom: 32, left: 26 }}>
|
<LineChart data={data} margin={{ top: 16, right: 64, bottom: 32, left: 18 }}>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis dataKey="date" tickLine={false} axisLine={false} tickFormatter={formatJalaliTick} minTickGap={34} />
|
<XAxis dataKey="date" tickLine={false} axisLine={false} tickFormatter={formatJalaliTick} minTickGap={34} tickMargin={10} />
|
||||||
<YAxis width={64} tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
<YAxis
|
||||||
|
orientation="right"
|
||||||
|
width={64}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||||
|
/>
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
<Line type="monotone" dataKey="likes" stroke="var(--color-likes)" strokeWidth={3} dot={false} />
|
<Line type="monotone" dataKey="likes" stroke="var(--color-likes)" strokeWidth={3} dot={false} />
|
||||||
<Line type="monotone" dataKey="saves" stroke="var(--color-saves)" strokeWidth={3} dot={false} />
|
<Line type="monotone" dataKey="saves" stroke="var(--color-saves)" strokeWidth={3} dot={false} />
|
||||||
@@ -522,68 +874,67 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularity"] }) {
|
function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popularity"] }) {
|
||||||
const data = group.top_items;
|
const data = toPostEngagementData(group.top_items);
|
||||||
|
const allPosts = group.items?.length ? group.items : group.top_items;
|
||||||
|
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||||
|
const labelAxisWidth = axisWidth(data.map((post) => ({ label: post.title, value: post.score })));
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle>محبوبیت نوشتهها</CardTitle>
|
<CardTitle className="text-base sm:text-lg">محبوبیت نوشتهها</CardTitle>
|
||||||
<CardDescription>لایک در برابر ذخیره؛ اندازه نقطه بر اساس تعداد کامنت</CardDescription>
|
<CardDescription>رتبهبندی بر اساس مجموع لایک، ذخیره و کامنت</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
|
||||||
{!data.length ? (
|
{!data.length ? (
|
||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChartViewport minWidth={620}>
|
<ChartViewport minWidth={460}>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{
|
config={{
|
||||||
saves: { label: "ذخیره", color: PALETTE.cyan },
|
|
||||||
likes: { label: "لایک", color: PALETTE.rose },
|
likes: { label: "لایک", color: PALETTE.rose },
|
||||||
|
saves: { label: "ذخیره", color: PALETTE.cyan },
|
||||||
|
comments: { label: "کامنت", color: PALETTE.amber },
|
||||||
}}
|
}}
|
||||||
className="h-[340px] w-full"
|
className="w-full"
|
||||||
|
style={{ height: chartHeight(data.length, 320) }}
|
||||||
>
|
>
|
||||||
<ScatterChart margin={{ top: 18, right: 18, bottom: 36, left: 30 }}>
|
<BarChart data={data} layout="vertical" margin={{ top: 12, right: labelAxisWidth + 16, bottom: 24, left: 20 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
dataKey="likes"
|
reversed
|
||||||
name="لایک"
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
type="number"
|
dataKey="label"
|
||||||
dataKey="saves"
|
type="category"
|
||||||
name="ذخیره"
|
orientation="right"
|
||||||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
width={labelAxisWidth}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
tickFormatter={(value) => truncateLabel(String(value), 22)}
|
||||||
/>
|
/>
|
||||||
<ZAxis type="number" dataKey="comments" range={[70, 380]} />
|
<ChartTooltip content={<PostEngagementTooltip />} />
|
||||||
<ChartTooltip
|
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
||||||
cursor={{ strokeDasharray: "3 3" }}
|
<Bar dataKey="saves" stackId="engagement" fill="var(--color-saves)" radius={[0, 0, 0, 0]} />
|
||||||
content={({ active, payload }) => {
|
<Bar dataKey="comments" stackId="engagement" fill="var(--color-comments)" radius={[8, 8, 8, 8]} />
|
||||||
if (!active || !payload?.length) return null;
|
</BarChart>
|
||||||
const item = payload[0].payload as AnalyticsPostPopularitySchema;
|
|
||||||
return (
|
|
||||||
<div className="min-w-52 rounded-lg border bg-background p-3 text-xs shadow-xl" dir="rtl">
|
|
||||||
<p className="mb-2 max-w-72 font-semibold">{item.title}</p>
|
|
||||||
<div className="space-y-1 text-muted-foreground">
|
|
||||||
<p>لایک: {formatNumberPersian(item.likes)}</p>
|
|
||||||
<p>ذخیره: {formatNumberPersian(item.saves)}</p>
|
|
||||||
<p>کامنت: {formatNumberPersian(item.comments)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Scatter data={data} fill="var(--color-saves)" />
|
|
||||||
</ScatterChart>
|
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</ChartViewport>
|
</ChartViewport>
|
||||||
{group.total_count > data.length ? (
|
{group.total_count > data.length ? (
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-col gap-2 rounded-xl border bg-muted/20 p-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||||
نمایش {formatNumberPersian(data.length)} نوشته برتر از {formatNumberPersian(group.total_count)} نوشته دارای تعامل.
|
<span>نمودار خلاصه {formatNumberPersian(data.length)} نوشته اول را نشان میدهد.</span>
|
||||||
</p>
|
<Button type="button" size="sm" variant="secondary" className="shrink-0" onClick={() => setDetailsOpen(true)}>
|
||||||
|
مشاهده همه
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<BlogPostEngagementModal open={detailsOpen} onOpenChange={setDetailsOpen} posts={allPosts} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -594,11 +945,11 @@ function BlogScatterCard({ group }: { group: BlogAnalyticsSchema["post_popularit
|
|||||||
function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] }) {
|
function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle>رویدادهای برتر</CardTitle>
|
<CardTitle className="text-base sm:text-lg">رویدادهای برتر</CardTitle>
|
||||||
<CardDescription>بر اساس شرکتکننده تاییدشده، درآمد و زمان برگزاری</CardDescription>
|
<CardDescription>بر اساس شرکتکننده تاییدشده، درآمد و زمان برگزاری</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3 p-4 pt-0 sm:p-6 sm:pt-0">
|
||||||
{group.top_items.length ? (
|
{group.top_items.length ? (
|
||||||
group.top_items.map((event, index) => (
|
group.top_items.map((event, index) => (
|
||||||
<div key={event.id} className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3">
|
<div key={event.id} className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3">
|
||||||
@@ -629,11 +980,11 @@ function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] })
|
|||||||
function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
|
function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle>نوشتههای برتر</CardTitle>
|
<CardTitle className="text-base sm:text-lg">نوشتههای برتر</CardTitle>
|
||||||
<CardDescription>بر اساس جمع لایک، ذخیره و کامنت</CardDescription>
|
<CardDescription>بر اساس جمع لایک، ذخیره و کامنت</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3 p-4 pt-0 sm:p-6 sm:pt-0">
|
||||||
{posts.length ? (
|
{posts.length ? (
|
||||||
posts.map((post, index) => (
|
posts.map((post, index) => (
|
||||||
<div key={post.id} className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3">
|
<div key={post.id} className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3">
|
||||||
@@ -655,17 +1006,22 @@ function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UsersSection() {
|
function UsersSection({
|
||||||
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
}: {
|
||||||
|
filters: DateRangeState;
|
||||||
|
onFiltersChange: (filters: DateRangeState) => void;
|
||||||
|
}) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["admin", "analytics", "users", filters],
|
queryKey: ["admin", "analytics", "users", filters],
|
||||||
queryFn: () => api.getAdminUserAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
|
queryFn: () => api.getAdminUserAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6" dir="rtl">
|
||||||
<FilterCard title="فیلتر کاربران" description="این فیلتر فقط روی کاربران و تاریخ عضویت آنها اعمال میشود.">
|
<FilterCard title="فیلتر کاربران" description="این فیلتر فقط روی کاربران و تاریخ عضویت آنها اعمال میشود.">
|
||||||
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
|
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
|
||||||
</FilterCard>
|
</FilterCard>
|
||||||
{query.isLoading ? <SectionLoading /> : null}
|
{query.isLoading ? <SectionLoading /> : null}
|
||||||
{query.isError ? <SectionError error={query.error} /> : null}
|
{query.isError ? <SectionError error={query.error} /> : null}
|
||||||
@@ -701,8 +1057,13 @@ function UsersContent({ data }: { data: UserAnalyticsSchema }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventsSection() {
|
function EventsSection({
|
||||||
const [filters, setFilters] = React.useState<SectionState>({ from: "", to: "", eventId: null });
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
}: {
|
||||||
|
filters: SectionState;
|
||||||
|
onFiltersChange: (filters: SectionState) => void;
|
||||||
|
}) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["admin", "analytics", "events", filters],
|
queryKey: ["admin", "analytics", "events", filters],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -713,16 +1074,16 @@ function EventsSection() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const reset = () => setFilters({ from: "", to: "", eventId: null });
|
const reset = () => onFiltersChange({ from: "", to: "", eventId: null });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FilterCard title="فیلتر رویدادها" description="این فیلتر فقط روی آمار رویداد، ثبتنام، درآمد و تنوع شرکتکنندگان اعمال میشود.">
|
<FilterCard title="فیلتر رویدادها" description="این فیلتر فقط روی آمار رویداد، ثبتنام، درآمد و تنوع شرکتکنندگان اعمال میشود.">
|
||||||
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_1.2fr_auto]">
|
<div className="grid gap-3 xl:grid-cols-[2fr_1.2fr]">
|
||||||
<div className="xl:col-span-2">
|
<div>
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
value={filters}
|
value={filters}
|
||||||
onChange={(next) => setFilters((current) => ({ ...current, ...next }))}
|
onChange={(next) => onFiltersChange({ ...filters, ...next })}
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,7 +1091,7 @@ function EventsSection() {
|
|||||||
<Label>رویداد</Label>
|
<Label>رویداد</Label>
|
||||||
<AsyncSearchableCombobox
|
<AsyncSearchableCombobox
|
||||||
value={filters.eventId ?? null}
|
value={filters.eventId ?? null}
|
||||||
onChange={(eventId) => setFilters((current) => ({ ...current, eventId }))}
|
onChange={(eventId) => onFiltersChange({ ...filters, eventId })}
|
||||||
loadOptions={async ({ search, limit, offset }) => {
|
loadOptions={async ({ search, limit, offset }) => {
|
||||||
const data = await api.getAdminDashboardEventOptions({ search, limit, offset });
|
const data = await api.getAdminDashboardEventOptions({ search, limit, offset });
|
||||||
return {
|
return {
|
||||||
@@ -746,11 +1107,6 @@ function EventsSection() {
|
|||||||
emptyText="رویدادی پیدا نشد."
|
emptyText="رویدادی پیدا نشد."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden items-end xl:flex">
|
|
||||||
<Button variant="outline" onClick={reset}>
|
|
||||||
پاککردن
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</FilterCard>
|
</FilterCard>
|
||||||
{query.isLoading ? <SectionLoading /> : null}
|
{query.isLoading ? <SectionLoading /> : null}
|
||||||
@@ -799,8 +1155,13 @@ function EventsContent({ data }: { data: EventAnalyticsSchema }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BlogSection() {
|
function BlogSection({
|
||||||
const [filters, setFilters] = React.useState<DateRangeState>({ from: "", to: "" });
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
}: {
|
||||||
|
filters: DateRangeState;
|
||||||
|
onFiltersChange: (filters: DateRangeState) => void;
|
||||||
|
}) {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["admin", "analytics", "blog", filters],
|
queryKey: ["admin", "analytics", "blog", filters],
|
||||||
queryFn: () => api.getAdminBlogAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
|
queryFn: () => api.getAdminBlogAnalytics({ date_from: filters.from || undefined, date_to: filters.to || undefined }),
|
||||||
@@ -809,7 +1170,7 @@ function BlogSection() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FilterCard title="فیلتر بلاگ" description="این فیلتر فقط روی نوشتهها و تعاملات بلاگ اعمال میشود و به رویدادها وابسته نیست.">
|
<FilterCard title="فیلتر بلاگ" description="این فیلتر فقط روی نوشتهها و تعاملات بلاگ اعمال میشود و به رویدادها وابسته نیست.">
|
||||||
<DateRangeFilter value={filters} onChange={setFilters} onReset={() => setFilters({ from: "", to: "" })} />
|
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
|
||||||
</FilterCard>
|
</FilterCard>
|
||||||
{query.isLoading ? <SectionLoading /> : null}
|
{query.isLoading ? <SectionLoading /> : null}
|
||||||
{query.isError ? <SectionError error={query.error} /> : null}
|
{query.isError ? <SectionError error={query.error} /> : null}
|
||||||
@@ -829,7 +1190,7 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
<div className="grid gap-4 xl:grid-cols-3">
|
||||||
<div className="xl:col-span-2">
|
<div className="xl:col-span-2">
|
||||||
<BlogScatterCard group={data.post_popularity} />
|
<BlogEngagementCard group={data.post_popularity} />
|
||||||
</div>
|
</div>
|
||||||
<ActivityTrendCard data={data.activity_trend} />
|
<ActivityTrendCard data={data.activity_trend} />
|
||||||
</div>
|
</div>
|
||||||
@@ -843,19 +1204,46 @@ function BlogContent({ data }: { data: BlogAnalyticsSchema }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [activeTab, setActiveTab] = React.useState<DashboardTab>(() => parseTab(searchParams.get("tab")));
|
||||||
|
const [usersFilters, setUsersFilters] = React.useState<DateRangeState>(() => readDateRange(searchParams, "users"));
|
||||||
|
const [eventsFilters, setEventsFilters] = React.useState<SectionState>(() => ({
|
||||||
|
...readDateRange(searchParams, "events"),
|
||||||
|
eventId: searchParams.get("event_id"),
|
||||||
|
}));
|
||||||
|
const [blogFilters, setBlogFilters] = React.useState<DateRangeState>(() => readDateRange(searchParams, "blog"));
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("tab", activeTab);
|
||||||
|
setParam(params, "users_from", usersFilters.from);
|
||||||
|
setParam(params, "users_to", usersFilters.to);
|
||||||
|
setParam(params, "events_from", eventsFilters.from);
|
||||||
|
setParam(params, "events_to", eventsFilters.to);
|
||||||
|
setParam(params, "event_id", eventsFilters.eventId);
|
||||||
|
setParam(params, "blog_from", blogFilters.from);
|
||||||
|
setParam(params, "blog_to", blogFilters.to);
|
||||||
|
|
||||||
|
const nextSearch = params.toString();
|
||||||
|
const currentSearch = searchParams.toString();
|
||||||
|
if (nextSearch !== currentSearch) {
|
||||||
|
router.replace(`${pathname}?${nextSearch}`, { scroll: false });
|
||||||
|
}
|
||||||
|
}, [activeTab, blogFilters, eventsFilters, pathname, router, searchParams, usersFilters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<div className="space-y-6" dir="rtl">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<Tabs dir="rtl" value={activeTab} onValueChange={(value) => setActiveTab(parseTab(value))} className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-black tracking-tight">داشبورد دستاوردها</h2>
|
<h2 className="text-xl font-black tracking-tight sm:text-2xl">داشبورد دستاوردها</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-xs leading-6 text-muted-foreground sm:text-sm">
|
||||||
گزارشهای جداگانه برای کاربران، رویدادها و بلاگ با فیلترهای مستقل و خوانا
|
گزارشهای جداگانه برای کاربران، رویدادها و بلاگ با فیلترهای مستقل و خوانا
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<TabsList className="grid h-auto w-full grid-cols-3 rounded-2xl p-1 lg:w-fit">
|
||||||
|
|
||||||
<Tabs defaultValue="users" className="space-y-6">
|
|
||||||
<TabsList className="grid h-auto w-full grid-cols-3 rounded-2xl p-1 sm:w-fit">
|
|
||||||
<TabsTrigger value="users" className="gap-2 rounded-xl py-2">
|
<TabsTrigger value="users" className="gap-2 rounded-xl py-2">
|
||||||
<UsersRound className="h-4 w-4" />
|
<UsersRound className="h-4 w-4" />
|
||||||
کاربران
|
کاربران
|
||||||
@@ -869,14 +1257,15 @@ export default function AdminDashboard() {
|
|||||||
بلاگ
|
بلاگ
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
</div>
|
||||||
<TabsContent value="users">
|
<TabsContent value="users">
|
||||||
<UsersSection />
|
<UsersSection filters={usersFilters} onFiltersChange={setUsersFilters} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="events">
|
<TabsContent value="events">
|
||||||
<EventsSection />
|
<EventsSection filters={eventsFilters} onFiltersChange={setEventsFilters} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="blog">
|
<TabsContent value="blog">
|
||||||
<BlogSection />
|
<BlogSection filters={blogFilters} onFiltersChange={setBlogFilters} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
ChevronDown,
|
|
||||||
FileText,
|
FileText,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
GraduationCap,
|
GraduationCap,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
PanelRightClose,
|
Menu,
|
||||||
PanelRightOpen,
|
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Tags,
|
Tags,
|
||||||
TicketPercent,
|
TicketPercent,
|
||||||
@@ -20,7 +18,7 @@ import {
|
|||||||
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
import { Navigate, NavLink, useLocation } from "@/lib/router";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navGroups = [
|
const navGroups = [
|
||||||
@@ -65,31 +63,11 @@ type NavItem = (typeof navGroups)[number]["items"][number];
|
|||||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, isAuthenticated, loading } = useAuth();
|
const { user, isAuthenticated, loading } = useAuth();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
|
|
||||||
dashboard: true,
|
|
||||||
users: true,
|
|
||||||
events: true,
|
|
||||||
blog: true,
|
|
||||||
});
|
|
||||||
const canAccessAdmin = useMemo(
|
const canAccessAdmin = useMemo(
|
||||||
() => isAuthenticated && Boolean(user?.is_staff || user?.is_superuser || user?.can_access_blog_admin),
|
() => 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],
|
[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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground" dir="rtl">
|
||||||
@@ -112,7 +90,6 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
const visibleGroups = navGroups
|
const visibleGroups = navGroups
|
||||||
.map((group) => ({ ...group, items: group.items.filter(canSeeItem) }))
|
.map((group) => ({ ...group, items: group.items.filter(canSeeItem) }))
|
||||||
.filter((group) => group.items.length > 0);
|
.filter((group) => group.items.length > 0);
|
||||||
const visibleNavItems = visibleGroups.flatMap((group) => group.items);
|
|
||||||
|
|
||||||
const isItemActive = (to: string) => {
|
const isItemActive = (to: string) => {
|
||||||
if (location.pathname === to) return true;
|
if (location.pathname === to) return true;
|
||||||
@@ -129,38 +106,16 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
<div className="min-h-screen bg-muted/15" dir="rtl">
|
<div className="min-h-screen bg-muted/15" dir="rtl">
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className="sticky top-0 hidden h-screen w-72 shrink-0 border-l bg-background/95 shadow-sm backdrop-blur lg:flex lg:flex-col"
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 border-b p-4">
|
<div className="border-b p-4 text-right">
|
||||||
{!collapsed ? (
|
|
||||||
<div className="text-right">
|
|
||||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||||
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
||||||
</div>
|
</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>
|
|
||||||
<nav className="flex-1 space-y-3 p-3">
|
<nav className="flex-1 space-y-3 p-3">
|
||||||
{visibleGroups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<Collapsible
|
<div key={group.key} className="space-y-2">
|
||||||
key={group.key}
|
<p className="px-3 py-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
|
||||||
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) => {
|
{group.items.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = isItemActive(item.to);
|
const active = isItemActive(item.to);
|
||||||
@@ -168,69 +123,84 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
title={collapsed ? item.label : undefined}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
|
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
|
||||||
collapsed ? "justify-center" : "justify-start",
|
|
||||||
active
|
active
|
||||||
? "bg-primary text-primary-foreground shadow"
|
? "bg-primary text-primary-foreground shadow"
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 shrink-0" />
|
<Icon className="h-5 w-5 shrink-0" />
|
||||||
{!collapsed ? <span className="font-medium">{item.label}</span> : null}
|
<span className="font-medium">{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</CollapsibleContent>
|
</div>
|
||||||
</Collapsible>
|
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="border-b bg-background/90 lg:hidden">
|
<div className="border-b bg-background/90 lg:hidden">
|
||||||
<div className="px-4 py-3 text-right">
|
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<div className="text-right">
|
||||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
||||||
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
<p className="text-xs text-muted-foreground">مدیریت بخشهای سامانه</p>
|
||||||
</div>
|
</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>
|
||||||
<div className="container mx-auto min-w-0 px-3 pb-28 pt-4 sm:px-4 lg:py-6">
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="container mx-auto min-w-0 px-3 pb-8 pt-4 sm:px-4 lg:py-6">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user