Compare commits

...

4 Commits

Author SHA1 Message Date
b64c6cf612 feat(admin): add collapsible desktop sidebar
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-15 21:53:39 +03:30
958400a8c1 feat(admin-dashboard): link top content lists 2026-06-15 21:50:42 +03:30
da8d82955e fix(admin-dashboard): refine filter reset controls 2026-06-15 21:46:20 +03:30
021bee9444 fix(admin-dashboard): prevent mobile chart overflow 2026-06-15 21:34:59 +03:30
2 changed files with 190 additions and 75 deletions

View File

@@ -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,16 +424,16 @@ 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: 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" /> <CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis <XAxis
@@ -415,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)" />
@@ -484,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
@@ -530,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 },
@@ -538,12 +573,12 @@ 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: 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" /> <CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis <XAxis
@@ -559,11 +594,11 @@ function BlogPostEngagementModal({
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]} />
@@ -651,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>
@@ -666,9 +702,13 @@ 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: 8, bottom: 24, left: 32 }}> <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"
@@ -683,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)" />
@@ -726,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" />
@@ -740,24 +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: 12, 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 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
@@ -791,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>
@@ -802,9 +847,13 @@ 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: 8, bottom: 28, left: 32 }}> <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"
@@ -819,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]}>
@@ -843,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>
@@ -860,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: 12, 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" 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 <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 />} />
@@ -890,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>
@@ -902,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 },
@@ -910,9 +964,13 @@ 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: 8, bottom: 24, left: 32 }}> <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"
@@ -929,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]} />
@@ -965,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">
@@ -975,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>
@@ -1000,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">
@@ -1008,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>
@@ -1034,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}
@@ -1092,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">
@@ -1120,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}
@@ -1183,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}

View File

@@ -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>
); );
})} })}