refactor(fe): replace TanStack Query with SWR + UI/data cleanup
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m56s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m59s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 2m56s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 3m42s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m59s
Rombak data layer frontend: - Hapus @tanstack/react-query (package.json, lockfile, provider di dashboard layout) — ganti SWR 2.4.2 + SWRConfig (revalidateOnFocus false, deduping 10s, no retry on 404) - Semua hooks data ditulis ulang ke useSWR; useAction() helper baru pengganti useMutation dengan surface kompatibel (mutate/mutateAsync/ isPending/error) - useMessages + useMessagesHasMore share satu SWR key — probe cursor yang tadinya dobel fetch API sekarang deduped - WS sync (messages/media/recordings) pindah dari queryClient ke SWR mutate dengan filter key + revalidate:false - useMessageSearch() dipakai search-panel & search-overlay; search overlay backdrop div -> button (fix a11y lint) Rapikan UI + isi data: - Tab stats recordings: placeholder 'coming soon' diganti stat asli (total, ukuran, speaker unik, top speakers) - Empty states konsisten via EmptyState (images/review/recordings), EmptyState terima className - biome check --write: 0 error, 8 warning pre-existing - Verifikasi: tsc --noEmit PASS, next build PASS (11 halaman static), API live dicek — semua endpoint dashboard/messages/guilds/config/ voice/media/recordings/review balikin data
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, RefreshCw, Search, Sparkles } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
@@ -11,8 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useReanalyze } from "@/hooks";
|
||||
import { messagesApi } from "@/lib/api";
|
||||
import { useMessageSearch, useReanalyze } from "@/hooks";
|
||||
import { renderMessageContent, safeParseJsonArray } from "@/lib/format";
|
||||
import type { MessageRecord } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -22,14 +20,10 @@ export function SearchPanel() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const reanalyzeMut = useReanalyze();
|
||||
|
||||
const { data: results, isFetching } = useQuery<MessageRecord[]>({
|
||||
queryKey: ["analysis-search", query],
|
||||
queryFn: async () => {
|
||||
const result = await messagesApi.search(query, 50);
|
||||
return result.results;
|
||||
},
|
||||
const { data: results, isValidating: isFetching } = useMessageSearch(
|
||||
query,
|
||||
enabled,
|
||||
});
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (!query.trim()) return;
|
||||
@@ -100,7 +94,9 @@ export function SearchPanel() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">{renderMessageContent(msg.content, msg.metadata)}</p>
|
||||
<p className="text-sm leading-relaxed">
|
||||
{renderMessageContent(msg.content, msg.metadata)}
|
||||
</p>
|
||||
{msg.ai_moderation_flags &&
|
||||
msg.ai_moderation_flags !== "[]" && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ChannelsSection({ guildId }: { guildId?: string }) {
|
||||
data: channels = [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
mutate: refetch,
|
||||
} = useChannels(guildId ?? "", search);
|
||||
const { data: detail } = useChannelDetail(selectedId);
|
||||
|
||||
@@ -124,7 +124,9 @@ export function ChannelsSection({ guildId }: { guildId?: string }) {
|
||||
className="rounded-lg border border-border/40 bg-card/40 px-3 py-2"
|
||||
>
|
||||
<p className="text-xs leading-relaxed text-text-secondary line-clamp-2">
|
||||
{msg.username}: {renderMessageContent(msg.content, msg.metadata) || "(no text content)"}
|
||||
{msg.username}:{" "}
|
||||
{renderMessageContent(msg.content, msg.metadata) ||
|
||||
"(no text content)"}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] font-mono text-text-secondary/40">
|
||||
{new Date(msg.created_at).toLocaleString()}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Area, AreaChart, ResponsiveContainer } from "recharts";
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
@@ -43,7 +43,10 @@ export function StatCard({
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-mono font-semibold tracking-tight" style={{ color: accentColor }}>
|
||||
<div
|
||||
className="text-2xl font-mono font-semibold tracking-tight"
|
||||
style={{ color: accentColor }}
|
||||
>
|
||||
{formatter(numValue)}
|
||||
</div>
|
||||
<div className="text-[11px] text-text-secondary font-medium mt-0.5 tracking-wide uppercase">
|
||||
@@ -56,7 +59,13 @@ export function StatCard({
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={sparklineData}>
|
||||
<defs>
|
||||
<linearGradient id={`spark-grad-${label}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<linearGradient
|
||||
id={`spark-grad-${label}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={accentColor} stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor={accentColor} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { GlassCard } from "@/components/glass/card";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
|
||||
interface TopChannelsChartProps {
|
||||
data?: { name: string; count: number }[];
|
||||
@@ -11,13 +18,27 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
|
||||
return (
|
||||
<GlassCard variant="base">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">Top Channels</span>
|
||||
<span className="text-xs font-semibold tracking-wide uppercase text-text-secondary">
|
||||
Top Channels
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} layout="vertical">
|
||||
<XAxis type="number" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} />
|
||||
<YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }} width={80} />
|
||||
<XAxis
|
||||
type="number"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fill: "oklch(0.55 0.02 245)", fontSize: 10 }}
|
||||
width={80}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: "oklch(0.11 0.02 245 / 0.9)",
|
||||
@@ -27,7 +48,11 @@ export function TopChannelsChart({ data = [] }: TopChannelsChartProps) {
|
||||
color: "oklch(0.93 0.01 245)",
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="var(--color-primary)" radius={[0, 4, 4, 0]} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="var(--color-primary)"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,12 @@ export function UsersSection() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data: users = [], isLoading, error, refetch } = useUsers(search);
|
||||
const {
|
||||
data: users = [],
|
||||
isLoading,
|
||||
error,
|
||||
mutate: refetch,
|
||||
} = useUsers(search);
|
||||
const { data: detail } = useUserDetail(selectedId);
|
||||
|
||||
const handleSearch = useCallback((v: string) => {
|
||||
@@ -135,7 +140,8 @@ export function UsersSection() {
|
||||
className="rounded-lg border border-border/40 bg-card/40 px-3 py-2"
|
||||
>
|
||||
<p className="text-xs leading-relaxed text-text-secondary line-clamp-2">
|
||||
{renderMessageContent(msg.content, msg.metadata) || "(no text content)"}
|
||||
{renderMessageContent(msg.content, msg.metadata) ||
|
||||
"(no text content)"}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] font-mono text-text-secondary/40">
|
||||
{msg.channel_id?.slice(0, 8)} ·{" "}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { messagesApi } from "@/lib/api";
|
||||
import { useMessageSearch } from "@/hooks";
|
||||
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
|
||||
import type { MessageRecord } from "@/lib/types";
|
||||
|
||||
@@ -17,14 +16,7 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: results } = useQuery<MessageRecord[]>({
|
||||
queryKey: ["messages-search", query],
|
||||
queryFn: async () => {
|
||||
const res = await messagesApi.search(query, 20);
|
||||
return res.results;
|
||||
},
|
||||
enabled: query.length >= 2,
|
||||
});
|
||||
const { data: results } = useMessageSearch(query, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -50,7 +42,12 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close search"
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full max-w-lg glass-intense rounded-[var(--radius-card)] overflow-hidden shadow-2xl">
|
||||
{/* Input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-glass-border">
|
||||
@@ -63,7 +60,11 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||
placeholder="Search messages..."
|
||||
className="flex-1 bg-transparent text-sm text-text-primary placeholder-text-secondary/40 outline-none"
|
||||
/>
|
||||
<button type="button" onClick={onClose} className="size-6 flex items-center justify-center rounded hover:bg-glass-bg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="size-6 flex items-center justify-center rounded hover:bg-glass-bg"
|
||||
>
|
||||
<X className="size-3.5 text-text-secondary/60" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -72,21 +73,32 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
|
||||
<div className="max-h-80 overflow-y-auto p-2 space-y-1">
|
||||
{!results || results.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-text-secondary/40">
|
||||
{query.length < 2 ? "Type at least 2 characters" : "No results found"}
|
||||
{query.length < 2
|
||||
? "Type at least 2 characters"
|
||||
: "No results found"}
|
||||
</div>
|
||||
) : (
|
||||
results.map((msg) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
type="button"
|
||||
onClick={() => { onSelect(msg.id); onClose(); }}
|
||||
onClick={() => {
|
||||
onSelect(msg.id);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 rounded-lg hover:bg-glass-bg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-text-primary">{msg.username}</span>
|
||||
<span className="text-text-secondary/40">{getMessageChannelLabel(msg)}</span>
|
||||
<span className="font-medium text-text-primary">
|
||||
{msg.username}
|
||||
</span>
|
||||
<span className="text-text-secondary/40">
|
||||
{getMessageChannelLabel(msg)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">{renderMessageContent(msg.content, msg.metadata)}</p>
|
||||
<p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">
|
||||
{renderMessageContent(msg.content, msg.metadata)}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -3,20 +3,26 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { GlassPanel } from "@/components/glass/panel";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title = "No data yet",
|
||||
description = "Nothing to display here yet.",
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<GlassPanel dense className="flex flex-col items-center gap-2 py-12">
|
||||
<GlassPanel
|
||||
dense
|
||||
className={cn("flex flex-col items-center gap-2 py-12", className)}
|
||||
>
|
||||
<Icon className="size-8 text-text-secondary/20" />
|
||||
<p className="text-sm text-text-secondary/60">{title}</p>
|
||||
<p className="text-xs text-text-secondary/40">{description}</p>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function GuildSelector({
|
||||
onChange,
|
||||
autoHide = true,
|
||||
}: GuildSelectorProps) {
|
||||
const { data: guilds = [], isLoading, error, refetch } = useGuilds();
|
||||
const { data: guilds = [], isLoading, error, mutate: refetch } = useGuilds();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const initDone = useRef(false);
|
||||
|
||||
Reference in New Issue
Block a user