fix(fe): crash 'reading channel_id' + recharts width/height warnings
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m14s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m53s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m15s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m14s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m53s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m15s
- useMessageDetail: guard attachments fetcher — revalidate bisa race detail load, detail.data undefined saat fetcher jalan -> TypeError 'Cannot read properties of undefined (reading channel_id)' yang bikin halaman /messages mati (Next error boundary). Sekarang fetcher balikin [] kalau channel_id belum ada; hapus non-null assertion. Diverifikasi: /messages sebelumnya crash, sekarang render dengan data asli (list, verdict, confidence, sticker, emoji). - ResponsiveContainer (recharts 3.8): initialDimension -1 di render pertama -> warning 'width(-1) and height(-1)'. Pakai height numerik tetap (192/160/48) + minWidth/minHeight 0 -> calculatedHeight >0, warning hilang; width tetap responsif via ResizeObserver. Chart baru dirender setelah mount (useMounted) biar container punya ukuran. Diverifikasi console: 0 warning, 0 error di dashboard & voice.
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Area, AreaChart, ResponsiveContainer } from "recharts";
|
import { Area, AreaChart, ResponsiveContainer } from "recharts";
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { useMounted } from "@/lib/hooks/use-mounted";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
@@ -22,6 +23,7 @@ export function StatCard({
|
|||||||
sparklineData,
|
sparklineData,
|
||||||
formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v),
|
formatter = (v) => (typeof v === "number" ? v.toLocaleString() : v),
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
|
const mounted = useMounted();
|
||||||
const accentColor = {
|
const accentColor = {
|
||||||
default: "var(--color-primary)",
|
default: "var(--color-primary)",
|
||||||
danger: "var(--color-destructive)",
|
danger: "var(--color-destructive)",
|
||||||
@@ -54,9 +56,14 @@ export function StatCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sparkline background */}
|
{/* Sparkline background */}
|
||||||
{sparklineData && sparklineData.length > 0 && (
|
{sparklineData && sparklineData.length > 0 && mounted && (
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-12 opacity-20">
|
<div className="absolute bottom-0 left-0 right-0 h-12 opacity-20">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer
|
||||||
|
width="100%"
|
||||||
|
height={48}
|
||||||
|
minWidth={0}
|
||||||
|
minHeight={0}
|
||||||
|
>
|
||||||
<AreaChart data={sparklineData}>
|
<AreaChart data={sparklineData}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { useMounted } from "@/lib/hooks/use-mounted";
|
||||||
|
|
||||||
interface TopChannelsChartProps {
|
interface TopChannelsChartProps {
|
||||||
data?: { name: string; count: number }[];
|
data?: { name: string; count: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
|
export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
|
||||||
|
const mounted = useMounted();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard variant="base">
|
<GlassCard variant="base">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
@@ -23,38 +26,47 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-48">
|
<div className="h-48">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{mounted ? (
|
||||||
<BarChart data={data} layout="vertical">
|
<ResponsiveContainer
|
||||||
<XAxis
|
width="100%"
|
||||||
type="number"
|
height={192}
|
||||||
axisLine={false}
|
minWidth={0}
|
||||||
tickLine={false}
|
minHeight={0}
|
||||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
>
|
||||||
/>
|
<BarChart data={data} layout="vertical">
|
||||||
<YAxis
|
<XAxis
|
||||||
type="category"
|
type="number"
|
||||||
dataKey="name"
|
axisLine={false}
|
||||||
axisLine={false}
|
tickLine={false}
|
||||||
tickLine={false}
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
/>
|
||||||
width={80}
|
<YAxis
|
||||||
/>
|
type="category"
|
||||||
<Tooltip
|
dataKey="name"
|
||||||
contentStyle={{
|
axisLine={false}
|
||||||
background: "oklch(0.11 0.02 245 / 0.9)",
|
tickLine={false}
|
||||||
border: "1px solid oklch(1 0 0 / 0.08)",
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||||
borderRadius: 8,
|
width={80}
|
||||||
fontSize: 12,
|
/>
|
||||||
color: "oklch(0.93 0.01 245)",
|
<Tooltip
|
||||||
}}
|
contentStyle={{
|
||||||
/>
|
background: "oklch(0.11 0.02 245 / 0.9)",
|
||||||
<Bar
|
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||||
dataKey="count"
|
borderRadius: 8,
|
||||||
fill="var(--color-primary)"
|
fontSize: 12,
|
||||||
radius={[0, 4, 4, 0]}
|
color: "oklch(0.93 0.01 245)",
|
||||||
/>
|
}}
|
||||||
</BarChart>
|
/>
|
||||||
</ResponsiveContainer>
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="var(--color-primary)"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { GlassCard } from "@/components/glass/card";
|
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -9,12 +8,16 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { GlassCard } from "@/components/glass/card";
|
||||||
|
import { useMounted } from "@/lib/hooks/use-mounted";
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
data?: { user: string; duration: number }[];
|
data?: { user: string; duration: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
||||||
|
const mounted = useMounted();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard variant="base">
|
<GlassCard variant="base">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
@@ -23,39 +26,51 @@ export function VoiceActivityTimeline({ data = [] }: ActivityTimelineProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-40">
|
<div className="h-40">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{mounted ? (
|
||||||
<BarChart data={data} layout="vertical">
|
<ResponsiveContainer
|
||||||
<XAxis
|
width="100%"
|
||||||
type="number"
|
height={160}
|
||||||
axisLine={false}
|
minWidth={0}
|
||||||
tickLine={false}
|
minHeight={0}
|
||||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
>
|
||||||
/>
|
<BarChart data={data} layout="vertical">
|
||||||
<YAxis
|
<XAxis
|
||||||
type="category"
|
type="number"
|
||||||
dataKey="user"
|
axisLine={false}
|
||||||
axisLine={false}
|
tickLine={false}
|
||||||
tickLine={false}
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
/>
|
||||||
width={80}
|
<YAxis
|
||||||
/>
|
type="category"
|
||||||
<Tooltip
|
dataKey="user"
|
||||||
contentStyle={{
|
axisLine={false}
|
||||||
background: "oklch(0.11 0.02 245 / 0.9)",
|
tickLine={false}
|
||||||
border: "1px solid oklch(1 0 0 / 0.08)",
|
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||||
borderRadius: 8,
|
width={80}
|
||||||
fontSize: 12,
|
/>
|
||||||
color: "oklch(0.93 0.01 245)",
|
<Tooltip
|
||||||
}}
|
contentStyle={{
|
||||||
formatter={(value) => [`${(Number(value) / 60).toFixed(1)}m`, "Duration"]}
|
background: "oklch(0.11 0.02 245 / 0.9)",
|
||||||
/>
|
border: "1px solid oklch(1 0 0 / 0.08)",
|
||||||
<Bar
|
borderRadius: 8,
|
||||||
dataKey="duration"
|
fontSize: 12,
|
||||||
fill="var(--color-primary)"
|
color: "oklch(0.93 0.01 245)",
|
||||||
radius={[0, 4, 4, 0]}
|
}}
|
||||||
/>
|
formatter={(value) => [
|
||||||
</BarChart>
|
`${(Number(value) / 60).toFixed(1)}m`,
|
||||||
</ResponsiveContainer>
|
"Duration",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="duration"
|
||||||
|
fill="var(--color-primary)"
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full animate-pulse rounded-md bg-card/40" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -126,14 +126,17 @@ export function useReview(channelId?: string) {
|
|||||||
|
|
||||||
export function useMessageDetail(id: string | null) {
|
export function useMessageDetail(id: string | null) {
|
||||||
const detail = useSWR<MessageRecord>(id ? msgKeys.detail(id) : null, () =>
|
const detail = useSWR<MessageRecord>(id ? msgKeys.detail(id) : null, () =>
|
||||||
messagesApi.getDetail(id!),
|
messagesApi.getDetail(id ?? ""),
|
||||||
);
|
);
|
||||||
|
const channelId = id ? detail.data?.channel_id : undefined;
|
||||||
const attachments = useSWR<AttachmentRecord[]>(
|
const attachments = useSWR<AttachmentRecord[]>(
|
||||||
id && detail.data?.channel_id
|
channelId ? [...msgKeys.detail(id ?? ""), "attachments"] : null,
|
||||||
? [...msgKeys.detail(id), "attachments"]
|
|
||||||
: null,
|
|
||||||
async () => {
|
async () => {
|
||||||
const res = await messagesApi.getAttachments(detail.data!.channel_id, 10);
|
// Guard: only fetch when we actually have a channel id — a revalidate
|
||||||
|
// can race the detail load and see detail.data === undefined.
|
||||||
|
const cid = detail.data?.channel_id;
|
||||||
|
if (!cid) return [];
|
||||||
|
const res = await messagesApi.getAttachments(cid, 10);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true once the component has mounted on the client. Charts wrapped
|
||||||
|
* in ResponsiveContainer measure their parent during the first layout — if
|
||||||
|
* the container has no size yet (hydration/flex/grid), Recharts logs
|
||||||
|
* "width(-1) and height(-1)" warnings. Deferring the chart render until after
|
||||||
|
* mount guarantees the container has real dimensions.
|
||||||
|
*/
|
||||||
|
export function useMounted(): boolean {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user