Compare commits
5 Commits
f30d53df7e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b64c6cf612 | |||
| 958400a8c1 | |||
| da8d82955e | |||
| 021bee9444 | |||
| ecd4a57da9 |
@@ -42,6 +42,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
AnalyticsPointGroupSchema,
|
AnalyticsPointGroupSchema,
|
||||||
@@ -138,13 +139,13 @@ function truncateLabel(value: string, max = 18) {
|
|||||||
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chartHeight(count: number, min = 280) {
|
function chartHeight(count: number, min = 280, compact = false) {
|
||||||
return Math.max(min, count * 32 + 80);
|
return Math.max(compact ? Math.min(min, 240) : min, count * (compact ? 26 : 32) + (compact ? 64 : 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
function axisWidth(items: AnalyticsPointSchema[]) {
|
function axisWidth(items: AnalyticsPointSchema[], compact = false) {
|
||||||
const maxLength = Math.max(...items.map((item) => String(item.label).length), 10);
|
const maxLength = Math.max(...items.map((item) => String(item.label).length), 10);
|
||||||
return Math.min(190, Math.max(90, maxLength * 7));
|
return Math.min(compact ? 92 : 190, Math.max(compact ? 64 : 90, maxLength * (compact ? 4.5 : 7)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
|
function dataWithOtherNotice(group: AnalyticsPointGroupSchema) {
|
||||||
@@ -212,13 +213,17 @@ function DateRangeFilter({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onReset,
|
onReset,
|
||||||
|
resetDisabled,
|
||||||
|
showReset = true,
|
||||||
}: {
|
}: {
|
||||||
value: DateRangeState;
|
value: DateRangeState;
|
||||||
onChange: (next: DateRangeState) => void;
|
onChange: (next: DateRangeState) => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
|
resetDisabled?: boolean;
|
||||||
|
showReset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
<div className={cn("grid gap-3", showReset ? "md:grid-cols-[1fr_1fr_auto]" : "md:grid-cols-2")}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>از تاریخ</Label>
|
<Label>از تاریخ</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -247,16 +252,31 @@ function DateRangeFilter({
|
|||||||
containerClassName="w-full"
|
containerClassName="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
{showReset ? (
|
||||||
<Button variant="destructive" className="w-full gap-2 md:w-auto" onClick={onReset}>
|
<div className="flex items-end">
|
||||||
<Eraser className="h-4 w-4" />
|
<FilterResetButton disabled={resetDisabled ?? (!value.from && !value.to)} onClick={onReset} />
|
||||||
پاککردن
|
</div>
|
||||||
</Button>
|
) : null}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilterResetButton({ disabled, onClick }: { disabled: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full gap-2 md:w-10 md:px-0"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title="پاککردن"
|
||||||
|
aria-label="پاککردن فیلترها"
|
||||||
|
>
|
||||||
|
<Eraser className="h-4 w-4" />
|
||||||
|
<span className="md:sr-only">پاککردن</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterCard({
|
function FilterCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -280,10 +300,23 @@ function FilterCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartViewport({ children, minWidth = 420 }: { children: React.ReactNode; minWidth?: number }) {
|
function ChartViewport({
|
||||||
|
children,
|
||||||
|
minWidth = 420,
|
||||||
|
scrollable = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
minWidth?: number;
|
||||||
|
scrollable?: boolean;
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const shouldScroll = scrollable && !isMobile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto overflow-y-hidden pb-2" dir="ltr">
|
<div className={cn("min-w-0 max-w-full overflow-y-hidden pb-2", shouldScroll ? "overflow-x-auto" : "overflow-x-hidden")} dir="ltr">
|
||||||
<div style={{ minWidth }}>{children}</div>
|
<div className="min-w-0 max-w-full" style={{ width: "100%", minWidth: shouldScroll ? minWidth : undefined }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -354,6 +387,7 @@ function DashboardPointDetailModal({
|
|||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [sortBy, setSortBy] = React.useState<"value" | "label">("value");
|
const [sortBy, setSortBy] = React.useState<"value" | "label">("value");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const filteredData = React.useMemo(() => {
|
const filteredData = React.useMemo(() => {
|
||||||
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
||||||
const filtered = normalizedSearch
|
const filtered = normalizedSearch
|
||||||
@@ -390,21 +424,22 @@ function DashboardPointDetailModal({
|
|||||||
<EmptyChart label="موردی با این جستجو پیدا نشد." />
|
<EmptyChart label="موردی با این جستجو پیدا نشد." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChartViewport minWidth={680}>
|
<ChartViewport minWidth={680} scrollable>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{ value: { label: "مقدار", color } }}
|
config={{ value: { label: "مقدار", color } }}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: Math.min(Math.max(filteredData.length * 34 + 100, 360), 780) }}
|
style={{ height: Math.min(Math.max(filteredData.length * (isMobile ? 28 : 34) + 100, isMobile ? 300 : 360), 780) }}
|
||||||
>
|
>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
margin={{ top: 12, right: axisWidth(filteredData) + 16, bottom: 24, left: 20 }}
|
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 20 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
reversed
|
reversed
|
||||||
|
domain={[0, "dataMax"]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
@@ -414,11 +449,11 @@ function DashboardPointDetailModal({
|
|||||||
dataKey="label"
|
dataKey="label"
|
||||||
type="category"
|
type="category"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
width={axisWidth(filteredData)}
|
width={axisWidth(filteredData, isMobile)}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 4 : 10}
|
||||||
tickFormatter={(value) => truncateLabel(String(value), 24)}
|
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 12 : 24)}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={formatter} />} />
|
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={formatter} />} />
|
||||||
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
||||||
@@ -483,6 +518,7 @@ function BlogPostEngagementModal({
|
|||||||
}) {
|
}) {
|
||||||
const [search, setSearch] = React.useState("");
|
const [search, setSearch] = React.useState("");
|
||||||
const [sortBy, setSortBy] = React.useState<"score" | "likes" | "saves" | "comments" | "title">("score");
|
const [sortBy, setSortBy] = React.useState<"score" | "likes" | "saves" | "comments" | "title">("score");
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const data = React.useMemo(() => {
|
const data = React.useMemo(() => {
|
||||||
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
||||||
const filtered = normalizedSearch
|
const filtered = normalizedSearch
|
||||||
@@ -529,7 +565,7 @@ function BlogPostEngagementModal({
|
|||||||
<EmptyChart label="نوشتهای با این جستجو پیدا نشد." />
|
<EmptyChart label="نوشتهای با این جستجو پیدا نشد." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChartViewport minWidth={760}>
|
<ChartViewport minWidth={760} scrollable>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{
|
config={{
|
||||||
likes: { label: "لایک", color: PALETTE.rose },
|
likes: { label: "لایک", color: PALETTE.rose },
|
||||||
@@ -537,24 +573,32 @@ function BlogPostEngagementModal({
|
|||||||
comments: { label: "کامنت", color: PALETTE.amber },
|
comments: { label: "کامنت", color: PALETTE.amber },
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: Math.min(Math.max(data.length * 34 + 100, 380), 820) }}
|
style={{ height: Math.min(Math.max(data.length * (isMobile ? 28 : 34) + 100, isMobile ? 320 : 380), 820) }}
|
||||||
>
|
>
|
||||||
<BarChart
|
<BarChart
|
||||||
data={data}
|
data={data}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
margin={{ top: 12, right: axisWidth(data.map((post) => ({ label: post.title, value: post.score }))) + 16, bottom: 24, left: 20 }}
|
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 20 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis type="number" reversed tickLine={false} axisLine={false} tickFormatter={(value) => formatNumberPersian(Number(value))} />
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
reversed
|
||||||
|
domain={[0, "dataMax"]}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
type="category"
|
type="category"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
width={axisWidth(data.map((post) => ({ label: post.title, value: post.score })))}
|
width={axisWidth(data.map((post) => ({ label: post.title, value: post.score })), isMobile)}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 4 : 10}
|
||||||
tickFormatter={(value) => truncateLabel(String(value), 26)}
|
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 12 : 26)}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<PostEngagementTooltip />} />
|
<ChartTooltip content={<PostEngagementTooltip />} />
|
||||||
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
||||||
@@ -642,8 +686,9 @@ function HorizontalBarCard({
|
|||||||
const data = dataWithOtherNotice(group);
|
const data = dataWithOtherNotice(group);
|
||||||
const allData = fullGroupItems(group);
|
const allData = fullGroupItems(group);
|
||||||
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader className="p-4 sm:p-6">
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
@@ -657,13 +702,18 @@ function HorizontalBarCard({
|
|||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{ value: { label: "مقدار", color } }}
|
config={{ value: { label: "مقدار", color } }}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: chartHeight(data.length) }}
|
style={{ height: chartHeight(data.length, 280, isMobile) }}
|
||||||
>
|
>
|
||||||
<BarChart data={data} layout="vertical" margin={{ top: 12, right: axisWidth(data) + 10, bottom: 24, left: 20 }}>
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
layout="vertical"
|
||||||
|
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 18 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||||
|
>
|
||||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
reversed
|
reversed
|
||||||
|
domain={[0, "dataMax"]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
@@ -673,11 +723,11 @@ function HorizontalBarCard({
|
|||||||
dataKey="label"
|
dataKey="label"
|
||||||
type="category"
|
type="category"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
width={axisWidth(data)}
|
width={axisWidth(data, isMobile)}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 4 : 10}
|
||||||
tickFormatter={(value) => truncateLabel(String(value))}
|
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 10 : 18)}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={valueFormatter} />} />
|
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={valueFormatter} />} />
|
||||||
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
||||||
@@ -716,8 +766,9 @@ function TrendLineCard({
|
|||||||
color?: string;
|
color?: string;
|
||||||
valueFormatter?: (value: number) => string;
|
valueFormatter?: (value: number) => string;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader className="p-4 sm:p-6">
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
<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" />
|
||||||
@@ -730,23 +781,27 @@ function TrendLineCard({
|
|||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
<ChartViewport>
|
<ChartViewport>
|
||||||
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[260px] w-full sm:h-[300px]">
|
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[220px] w-full sm:h-[300px]">
|
||||||
<LineChart data={data} margin={{ top: 16, right: 76, bottom: 32, left: 18 }}>
|
<LineChart
|
||||||
|
data={data}
|
||||||
|
margin={isMobile ? { top: 12, right: 4, bottom: 26, left: 8 } : { top: 16, right: 12, bottom: 32, left: 18 }}
|
||||||
|
>
|
||||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
|
reversed
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
minTickGap={34}
|
minTickGap={isMobile ? 18 : 34}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 6 : 10}
|
||||||
tickFormatter={formatJalaliTick}
|
tickFormatter={formatJalaliTick}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
orientation="right"
|
orientation="right"
|
||||||
width={76}
|
width={isMobile ? 46 : 76}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 4 : 10}
|
||||||
tickFormatter={(value) => valueFormatter(Number(value))}
|
tickFormatter={(value) => valueFormatter(Number(value))}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -780,8 +835,9 @@ function StatusChartCard({
|
|||||||
description: string;
|
description: string;
|
||||||
data: Array<{ status: string; label: string; value: number }>;
|
data: Array<{ status: string; label: string; value: number }>;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader className="p-4 sm:p-6">
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
@@ -791,13 +847,18 @@ function StatusChartCard({
|
|||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChartViewport minWidth={400}>
|
<ChartViewport>
|
||||||
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[260px] w-full sm:h-[300px]">
|
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[220px] w-full sm:h-[300px]">
|
||||||
<BarChart data={data} layout="vertical" margin={{ top: 14, right: 118, bottom: 28, left: 18 }}>
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
layout="vertical"
|
||||||
|
margin={isMobile ? { top: 10, right: 2, bottom: 22, left: 18 } : { top: 14, right: 8, bottom: 28, left: 32 }}
|
||||||
|
>
|
||||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
reversed
|
reversed
|
||||||
|
domain={[0, "dataMax"]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
@@ -807,11 +868,11 @@ function StatusChartCard({
|
|||||||
dataKey="label"
|
dataKey="label"
|
||||||
type="category"
|
type="category"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
width={108}
|
width={isMobile ? 74 : 108}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 4 : 10}
|
||||||
tickFormatter={(value) => truncateLabel(String(value), 14)}
|
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 9 : 14)}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<CustomValueTooltip />} />
|
<ChartTooltip content={<CustomValueTooltip />} />
|
||||||
<Bar dataKey="value" radius={[8, 8, 8, 8]}>
|
<Bar dataKey="value" radius={[8, 8, 8, 8]}>
|
||||||
@@ -831,8 +892,9 @@ function StatusChartCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend"] }) {
|
function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend"] }) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader className="p-4 sm:p-6">
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle className="text-base sm:text-lg">روند تعاملات بلاگ</CardTitle>
|
<CardTitle className="text-base sm:text-lg">روند تعاملات بلاگ</CardTitle>
|
||||||
<CardDescription>لایک، ذخیره و کامنت در بازه انتخابی</CardDescription>
|
<CardDescription>لایک، ذخیره و کامنت در بازه انتخابی</CardDescription>
|
||||||
@@ -848,17 +910,20 @@ 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-[280px] w-full sm:h-[320px]"
|
className="h-[230px] w-full sm:h-[320px]"
|
||||||
>
|
>
|
||||||
<LineChart data={data} margin={{ top: 16, right: 64, bottom: 32, left: 18 }}>
|
<LineChart
|
||||||
|
data={data}
|
||||||
|
margin={isMobile ? { top: 12, right: 4, bottom: 26, left: 8 } : { top: 16, right: 12, 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} tickMargin={10} />
|
<XAxis dataKey="date" reversed tickLine={false} axisLine={false} tickFormatter={formatJalaliTick} minTickGap={isMobile ? 18 : 34} tickMargin={isMobile ? 6 : 10} />
|
||||||
<YAxis
|
<YAxis
|
||||||
orientation="right"
|
orientation="right"
|
||||||
width={64}
|
width={isMobile ? 44 : 64}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 4 : 10}
|
||||||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
@@ -878,9 +943,10 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
|||||||
const data = toPostEngagementData(group.top_items);
|
const data = toPostEngagementData(group.top_items);
|
||||||
const allPosts = group.items?.length ? group.items : group.top_items;
|
const allPosts = group.items?.length ? group.items : group.top_items;
|
||||||
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||||
const labelAxisWidth = axisWidth(data.map((post) => ({ label: post.title, value: post.score })));
|
const isMobile = useIsMobile();
|
||||||
|
const labelAxisWidth = axisWidth(data.map((post) => ({ label: post.title, value: post.score })), isMobile);
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader className="p-4 sm:p-6">
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<CardTitle className="text-base sm:text-lg">محبوبیت نوشتهها</CardTitle>
|
<CardTitle className="text-base sm:text-lg">محبوبیت نوشتهها</CardTitle>
|
||||||
<CardDescription>رتبهبندی بر اساس مجموع لایک، ذخیره و کامنت</CardDescription>
|
<CardDescription>رتبهبندی بر اساس مجموع لایک، ذخیره و کامنت</CardDescription>
|
||||||
@@ -890,7 +956,7 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
|||||||
<EmptyChart />
|
<EmptyChart />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ChartViewport minWidth={460}>
|
<ChartViewport>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={{
|
config={{
|
||||||
likes: { label: "لایک", color: PALETTE.rose },
|
likes: { label: "لایک", color: PALETTE.rose },
|
||||||
@@ -898,13 +964,18 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
|||||||
comments: { label: "کامنت", color: PALETTE.amber },
|
comments: { label: "کامنت", color: PALETTE.amber },
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{ height: chartHeight(data.length, 320) }}
|
style={{ height: chartHeight(data.length, 320, isMobile) }}
|
||||||
>
|
>
|
||||||
<BarChart data={data} layout="vertical" margin={{ top: 12, right: labelAxisWidth + 16, bottom: 24, left: 20 }}>
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
layout="vertical"
|
||||||
|
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 18 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||||
|
>
|
||||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
type="number"
|
type="number"
|
||||||
reversed
|
reversed
|
||||||
|
domain={[0, "dataMax"]}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||||
@@ -916,8 +987,8 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
|||||||
width={labelAxisWidth}
|
width={labelAxisWidth}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickMargin={10}
|
tickMargin={isMobile ? 4 : 10}
|
||||||
tickFormatter={(value) => truncateLabel(String(value), 22)}
|
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 10 : 22)}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<PostEngagementTooltip />} />
|
<ChartTooltip content={<PostEngagementTooltip />} />
|
||||||
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
||||||
@@ -952,7 +1023,13 @@ function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] })
|
|||||||
<CardContent className="space-y-3 p-4 pt-0 sm:p-6 sm:pt-0">
|
<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">
|
<a
|
||||||
|
key={event.id}
|
||||||
|
href={`/events/${event.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3 transition hover:border-primary/50 hover:bg-muted/40"
|
||||||
|
>
|
||||||
<div className="min-w-0 text-right">
|
<div className="min-w-0 text-right">
|
||||||
<p className="truncate font-medium">{event.title}</p>
|
<p className="truncate font-medium">{event.title}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -962,7 +1039,7 @@ function TopEventsCard({ group }: { group: EventAnalyticsSchema["top_events"] })
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{toPersianDigits(index + 1)}</Badge>
|
<Badge variant="secondary">{toPersianDigits(index + 1)}</Badge>
|
||||||
</div>
|
</a>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">دادهای وجود ندارد.</p>
|
<p className="text-sm text-muted-foreground">دادهای وجود ندارد.</p>
|
||||||
@@ -987,7 +1064,13 @@ function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
|
|||||||
<CardContent className="space-y-3 p-4 pt-0 sm:p-6 sm:pt-0">
|
<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">
|
<a
|
||||||
|
key={post.id}
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between gap-3 rounded-xl border bg-background/70 p-3 transition hover:border-primary/50 hover:bg-muted/40"
|
||||||
|
>
|
||||||
<div className="min-w-0 text-right">
|
<div className="min-w-0 text-right">
|
||||||
<p className="truncate font-medium">{post.title}</p>
|
<p className="truncate font-medium">{post.title}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -995,8 +1078,7 @@ function TopPostsCard({ posts }: { posts: BlogAnalyticsSchema["top_posts"] }) {
|
|||||||
{formatNumberPersian(post.comments)} کامنت
|
{formatNumberPersian(post.comments)} کامنت
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{toPersianDigits(index + 1)}</Badge>
|
</a>
|
||||||
</div>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">دادهای وجود ندارد.</p>
|
<p className="text-sm text-muted-foreground">دادهای وجود ندارد.</p>
|
||||||
@@ -1021,7 +1103,12 @@ function UsersSection({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6" dir="rtl">
|
<div className="space-y-6" dir="rtl">
|
||||||
<FilterCard title="فیلتر کاربران" description="این فیلتر فقط روی کاربران و تاریخ عضویت آنها اعمال میشود.">
|
<FilterCard title="فیلتر کاربران" description="این فیلتر فقط روی کاربران و تاریخ عضویت آنها اعمال میشود.">
|
||||||
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
|
<DateRangeFilter
|
||||||
|
value={filters}
|
||||||
|
onChange={onFiltersChange}
|
||||||
|
onReset={() => onFiltersChange({ from: "", to: "" })}
|
||||||
|
resetDisabled={!filters.from && !filters.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}
|
||||||
@@ -1079,12 +1166,13 @@ function EventsSection({
|
|||||||
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-[2fr_1.2fr]">
|
<div className="grid gap-3 xl:grid-cols-[2fr_1.2fr_auto]">
|
||||||
<div>
|
<div>
|
||||||
<DateRangeFilter
|
<DateRangeFilter
|
||||||
value={filters}
|
value={filters}
|
||||||
onChange={(next) => onFiltersChange({ ...filters, ...next })}
|
onChange={(next) => onFiltersChange({ ...filters, ...next })}
|
||||||
onReset={reset}
|
onReset={reset}
|
||||||
|
showReset={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1107,6 +1195,9 @@ function EventsSection({
|
|||||||
emptyText="رویدادی پیدا نشد."
|
emptyText="رویدادی پیدا نشد."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<FilterResetButton disabled={!filters.from && !filters.to && !filters.eventId} onClick={reset} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FilterCard>
|
</FilterCard>
|
||||||
{query.isLoading ? <SectionLoading /> : null}
|
{query.isLoading ? <SectionLoading /> : null}
|
||||||
@@ -1170,7 +1261,12 @@ function BlogSection({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FilterCard title="فیلتر بلاگ" description="این فیلتر فقط روی نوشتهها و تعاملات بلاگ اعمال میشود و به رویدادها وابسته نیست.">
|
<FilterCard title="فیلتر بلاگ" description="این فیلتر فقط روی نوشتهها و تعاملات بلاگ اعمال میشود و به رویدادها وابسته نیست.">
|
||||||
<DateRangeFilter value={filters} onChange={onFiltersChange} onReset={() => onFiltersChange({ from: "", to: "" })} />
|
<DateRangeFilter
|
||||||
|
value={filters}
|
||||||
|
onChange={onFiltersChange}
|
||||||
|
onReset={() => onFiltersChange({ from: "", to: "" })}
|
||||||
|
resetDisabled={!filters.from && !filters.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}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
GraduationCap,
|
GraduationCap,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
|
PanelRightClose,
|
||||||
|
PanelRightOpen,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Tags,
|
Tags,
|
||||||
TicketPercent,
|
TicketPercent,
|
||||||
@@ -63,6 +65,7 @@ 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 [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
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],
|
||||||
@@ -106,16 +109,43 @@ 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="sticky top-0 hidden h-screen w-72 shrink-0 border-l bg-background/95 shadow-sm backdrop-blur lg:flex lg:flex-col"
|
className={cn(
|
||||||
|
"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="border-b p-4 text-right">
|
<div className={cn("border-b p-4", sidebarCollapsed ? "text-center" : "text-right")}>
|
||||||
<h1 className="text-lg font-bold">پنل مدیریت</h1>
|
<div className={cn("flex items-center gap-2", sidebarCollapsed ? "justify-center" : "justify-start")}>
|
||||||
<p className="text-xs text-muted-foreground">انجمن علمی مهندسی کامپیوتر</p>
|
<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>
|
</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) => (
|
||||||
<div key={group.key} className="space-y-2">
|
<div key={group.key} className="space-y-2">
|
||||||
<p className="px-3 py-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
|
<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) => {
|
{group.items.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = isItemActive(item.to);
|
const active = isItemActive(item.to);
|
||||||
@@ -123,15 +153,17 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
|||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
title={sidebarCollapsed ? item.label : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm transition",
|
"flex items-center rounded-2xl px-3 py-3 text-sm transition",
|
||||||
|
sidebarCollapsed ? "justify-center" : "gap-3",
|
||||||
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" />
|
||||||
<span className="font-medium">{item.label}</span>
|
<span className={cn("font-medium", sidebarCollapsed && "sr-only")}>{item.label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user