fix(admin-dashboard): prevent mobile chart overflow
This commit is contained in:
@@ -42,6 +42,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
AnalyticsPointGroupSchema,
|
||||
@@ -138,13 +139,13 @@ function truncateLabel(value: string, max = 18) {
|
||||
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
||||
}
|
||||
|
||||
function chartHeight(count: number, min = 280) {
|
||||
return Math.max(min, count * 32 + 80);
|
||||
function chartHeight(count: number, min = 280, compact = false) {
|
||||
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);
|
||||
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) {
|
||||
@@ -280,10 +281,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 (
|
||||
<div className="overflow-x-auto overflow-y-hidden pb-2" dir="ltr">
|
||||
<div style={{ minWidth }}>{children}</div>
|
||||
<div className={cn("min-w-0 max-w-full overflow-y-hidden pb-2", shouldScroll ? "overflow-x-auto" : "overflow-x-hidden")} dir="ltr">
|
||||
<div className="min-w-0 max-w-full" style={{ width: "100%", minWidth: shouldScroll ? minWidth : undefined }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -354,6 +368,7 @@ function DashboardPointDetailModal({
|
||||
}) {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortBy, setSortBy] = React.useState<"value" | "label">("value");
|
||||
const isMobile = useIsMobile();
|
||||
const filteredData = React.useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
||||
const filtered = normalizedSearch
|
||||
@@ -390,16 +405,16 @@ function DashboardPointDetailModal({
|
||||
<EmptyChart label="موردی با این جستجو پیدا نشد." />
|
||||
) : (
|
||||
<>
|
||||
<ChartViewport minWidth={680}>
|
||||
<ChartViewport minWidth={680} scrollable>
|
||||
<ChartContainer
|
||||
config={{ value: { label: "مقدار", color } }}
|
||||
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
|
||||
data={filteredData}
|
||||
layout="vertical"
|
||||
margin={{ top: 12, right: 8, bottom: 24, left: 32 }}
|
||||
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 20 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||
>
|
||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
@@ -415,11 +430,11 @@ function DashboardPointDetailModal({
|
||||
dataKey="label"
|
||||
type="category"
|
||||
orientation="right"
|
||||
width={axisWidth(filteredData)}
|
||||
width={axisWidth(filteredData, isMobile)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), 24)}
|
||||
tickMargin={isMobile ? 4 : 10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 12 : 24)}
|
||||
/>
|
||||
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={formatter} />} />
|
||||
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
||||
@@ -484,6 +499,7 @@ function BlogPostEngagementModal({
|
||||
}) {
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortBy, setSortBy] = React.useState<"score" | "likes" | "saves" | "comments" | "title">("score");
|
||||
const isMobile = useIsMobile();
|
||||
const data = React.useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase("fa-IR");
|
||||
const filtered = normalizedSearch
|
||||
@@ -530,7 +546,7 @@ function BlogPostEngagementModal({
|
||||
<EmptyChart label="نوشتهای با این جستجو پیدا نشد." />
|
||||
) : (
|
||||
<>
|
||||
<ChartViewport minWidth={760}>
|
||||
<ChartViewport minWidth={760} scrollable>
|
||||
<ChartContainer
|
||||
config={{
|
||||
likes: { label: "لایک", color: PALETTE.rose },
|
||||
@@ -538,12 +554,12 @@ function BlogPostEngagementModal({
|
||||
comments: { label: "کامنت", color: PALETTE.amber },
|
||||
}}
|
||||
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
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 12, right: 8, bottom: 24, left: 32 }}
|
||||
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 20 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||
>
|
||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
@@ -559,11 +575,11 @@ function BlogPostEngagementModal({
|
||||
dataKey="label"
|
||||
type="category"
|
||||
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}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), 26)}
|
||||
tickMargin={isMobile ? 4 : 10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 12 : 26)}
|
||||
/>
|
||||
<ChartTooltip content={<PostEngagementTooltip />} />
|
||||
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
||||
@@ -651,8 +667,9 @@ function HorizontalBarCard({
|
||||
const data = dataWithOtherNotice(group);
|
||||
const allData = fullGroupItems(group);
|
||||
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<Card>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
@@ -666,9 +683,13 @@ function HorizontalBarCard({
|
||||
<ChartContainer
|
||||
config={{ value: { label: "مقدار", color } }}
|
||||
className="w-full"
|
||||
style={{ height: chartHeight(data.length) }}
|
||||
style={{ height: chartHeight(data.length, 280, isMobile) }}
|
||||
>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 18 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||
>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 12, right: 8, bottom: 24, left: 32 }}>
|
||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number"
|
||||
@@ -683,11 +704,11 @@ function HorizontalBarCard({
|
||||
dataKey="label"
|
||||
type="category"
|
||||
orientation="right"
|
||||
width={axisWidth(data)}
|
||||
width={axisWidth(data, isMobile)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => truncateLabel(String(value))}
|
||||
tickMargin={isMobile ? 4 : 10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 10 : 18)}
|
||||
/>
|
||||
<ChartTooltip content={<CustomValueTooltip unit={unit} formatter={valueFormatter} />} />
|
||||
<Bar dataKey="value" radius={[8, 8, 8, 8]} fill="var(--color-value)" />
|
||||
@@ -726,8 +747,9 @@ function TrendLineCard({
|
||||
color?: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<Card>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<LineChartIcon className="h-5 w-5 text-primary" />
|
||||
@@ -740,24 +762,27 @@ function TrendLineCard({
|
||||
<EmptyChart />
|
||||
) : (
|
||||
<ChartViewport>
|
||||
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[260px] w-full sm:h-[300px]">
|
||||
<LineChart data={data} margin={{ top: 16, right: 12, bottom: 32, left: 18 }}>
|
||||
<ChartContainer config={{ value: { label: "مقدار", color } }} className="h-[220px] w-full sm:h-[300px]">
|
||||
<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" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
reversed
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={34}
|
||||
tickMargin={10}
|
||||
minTickGap={isMobile ? 18 : 34}
|
||||
tickMargin={isMobile ? 6 : 10}
|
||||
tickFormatter={formatJalaliTick}
|
||||
/>
|
||||
<YAxis
|
||||
orientation="right"
|
||||
width={76}
|
||||
width={isMobile ? 46 : 76}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickMargin={isMobile ? 4 : 10}
|
||||
tickFormatter={(value) => valueFormatter(Number(value))}
|
||||
/>
|
||||
<ChartTooltip
|
||||
@@ -791,8 +816,9 @@ function StatusChartCard({
|
||||
description: string;
|
||||
data: Array<{ status: string; label: string; value: number }>;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<Card>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="text-base sm:text-lg">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
@@ -802,9 +828,13 @@ function StatusChartCard({
|
||||
<EmptyChart />
|
||||
) : (
|
||||
<>
|
||||
<ChartViewport minWidth={400}>
|
||||
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[260px] w-full sm:h-[300px]">
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 14, right: 8, bottom: 28, left: 32 }}>
|
||||
<ChartViewport>
|
||||
<ChartContainer config={{ value: { label: "تعداد", color: PALETTE.teal } }} className="h-[220px] w-full sm:h-[300px]">
|
||||
<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" />
|
||||
<XAxis
|
||||
type="number"
|
||||
@@ -819,11 +849,11 @@ function StatusChartCard({
|
||||
dataKey="label"
|
||||
type="category"
|
||||
orientation="right"
|
||||
width={108}
|
||||
width={isMobile ? 74 : 108}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), 14)}
|
||||
tickMargin={isMobile ? 4 : 10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 9 : 14)}
|
||||
/>
|
||||
<ChartTooltip content={<CustomValueTooltip />} />
|
||||
<Bar dataKey="value" radius={[8, 8, 8, 8]}>
|
||||
@@ -843,8 +873,9 @@ function StatusChartCard({
|
||||
}
|
||||
|
||||
function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend"] }) {
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<Card>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="text-base sm:text-lg">روند تعاملات بلاگ</CardTitle>
|
||||
<CardDescription>لایک، ذخیره و کامنت در بازه انتخابی</CardDescription>
|
||||
@@ -860,17 +891,20 @@ function ActivityTrendCard({ data }: { data: BlogAnalyticsSchema["activity_trend
|
||||
saves: { label: "ذخیره", color: PALETTE.cyan },
|
||||
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={isMobile ? { top: 12, right: 4, bottom: 26, left: 8 } : { top: 16, right: 12, bottom: 32, left: 18 }}
|
||||
>
|
||||
<LineChart data={data} margin={{ top: 16, right: 12, bottom: 32, left: 18 }}>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" reversed 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
|
||||
orientation="right"
|
||||
width={64}
|
||||
width={isMobile ? 44 : 64}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickMargin={isMobile ? 4 : 10}
|
||||
tickFormatter={(value) => formatNumberPersian(Number(value))}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
@@ -890,9 +924,10 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
||||
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 })));
|
||||
const isMobile = useIsMobile();
|
||||
const labelAxisWidth = axisWidth(data.map((post) => ({ label: post.title, value: post.score })), isMobile);
|
||||
return (
|
||||
<Card>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="p-4 sm:p-6">
|
||||
<CardTitle className="text-base sm:text-lg">محبوبیت نوشتهها</CardTitle>
|
||||
<CardDescription>رتبهبندی بر اساس مجموع لایک، ذخیره و کامنت</CardDescription>
|
||||
@@ -902,7 +937,7 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
||||
<EmptyChart />
|
||||
) : (
|
||||
<>
|
||||
<ChartViewport minWidth={460}>
|
||||
<ChartViewport>
|
||||
<ChartContainer
|
||||
config={{
|
||||
likes: { label: "لایک", color: PALETTE.rose },
|
||||
@@ -910,9 +945,13 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
||||
comments: { label: "کامنت", color: PALETTE.amber },
|
||||
}}
|
||||
className="w-full"
|
||||
style={{ height: chartHeight(data.length, 320) }}
|
||||
style={{ height: chartHeight(data.length, 320, isMobile) }}
|
||||
>
|
||||
<BarChart
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={isMobile ? { top: 8, right: 2, bottom: 22, left: 18 } : { top: 12, right: 8, bottom: 24, left: 32 }}
|
||||
>
|
||||
<BarChart data={data} layout="vertical" margin={{ top: 12, right: 8, bottom: 24, left: 32 }}>
|
||||
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
type="number"
|
||||
@@ -929,8 +968,8 @@ function BlogEngagementCard({ group }: { group: BlogAnalyticsSchema["post_popula
|
||||
width={labelAxisWidth}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), 22)}
|
||||
tickMargin={isMobile ? 4 : 10}
|
||||
tickFormatter={(value) => truncateLabel(String(value), isMobile ? 10 : 22)}
|
||||
/>
|
||||
<ChartTooltip content={<PostEngagementTooltip />} />
|
||||
<Bar dataKey="likes" stackId="engagement" fill="var(--color-likes)" radius={[0, 0, 0, 0]} />
|
||||
|
||||
Reference in New Issue
Block a user